fix(files): keep large source previews responsive (#3665)
Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.
This commit is contained in:
@@ -90,6 +90,87 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("CodeMirror workspace file editing", () => {
|
test.describe("CodeMirror workspace file editing", () => {
|
||||||
|
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-lockfile-",
|
||||||
|
title: "Large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
const lockfile = `${'{"packages":['}${Array.from({ length: 42_000 }, (_, index) => `{"name":"package-${index}","version":"1.0.0"}`).join(",")}]}`;
|
||||||
|
await writeFile(path.join(session.cwd, "package-lock.json"), lockfile, "utf8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "package-lock.json");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(editor(page)).toContainText('"package-0"');
|
||||||
|
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the app interactive around a plain 11 MB source", async ({ page }) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-plain-",
|
||||||
|
title: "Plain large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(session.cwd, "plain.txt"),
|
||||||
|
"plain source\n".repeat(1_050_000),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "plain.txt");
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
await expect(editor(page)).toContainText("plain source");
|
||||||
|
await expect.poll(() => page.locator(".cm-line").count()).toBeLessThan(200);
|
||||||
|
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||||
|
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||||
|
await page.getByTestId("workspace-tab-file_plain.txt").first().click();
|
||||||
|
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses a file above the display budget and keeps its tab recoverable", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "file-source-unsupported-",
|
||||||
|
title: "Unsupported large source",
|
||||||
|
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||||
|
});
|
||||||
|
await writeFile(
|
||||||
|
path.join(session.cwd, "too-large.txt"),
|
||||||
|
Buffer.alloc(51 * 1024 * 1024),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openAgentRoute(page, session);
|
||||||
|
await openWorkspaceFile(page, "too-large.txt");
|
||||||
|
await expect(page.getByTestId("file-source-too-large")).toContainText(
|
||||||
|
"This file is too large to display",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().click();
|
||||||
|
await expect(page.getByTestId("message-input-root")).toBeVisible();
|
||||||
|
await page.getByTestId("workspace-tab-file_too-large.txt").first().click();
|
||||||
|
await expect(page.getByTestId("file-source-too-large")).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await session.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||||
const target = "target.ts:42";
|
const target = "target.ts:42";
|
||||||
const session = await seedAgentWithFileLink({
|
const session = await seedAgentWithFileLink({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import { LiveFileModel, type LiveFileSession } from "./model";
|
import { LiveFileModel, type LiveFileSession } from "./model";
|
||||||
|
import { SOURCE_PRESENTATION_BUDGETS } from "../source/presentation";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
export function useLiveFile(input: {
|
export function useLiveFile(input: {
|
||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
@@ -18,7 +20,12 @@ export function useLiveFile(input: {
|
|||||||
return client.subscribeFile(target, onVersion);
|
return client.subscribeFile(target, onVersion);
|
||||||
},
|
},
|
||||||
read(target) {
|
read(target) {
|
||||||
return client.readFile(target.cwd, target.path);
|
return client.readFile(
|
||||||
|
target.cwd,
|
||||||
|
target.path,
|
||||||
|
undefined,
|
||||||
|
SOURCE_PRESENTATION_BUDGETS[isWeb ? "web" : "native"].plain,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [input.client]);
|
}, [input.client]);
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unisty
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
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 { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
import { getFileNameFromPath } from "@/attachments/utils";
|
import { getFileNameFromPath } from "@/attachments/utils";
|
||||||
@@ -38,6 +33,7 @@ import { FileMarkdownPreview } from "./markdown-preview";
|
|||||||
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model";
|
||||||
import { createFileObservationSource } from "./editor/observation-source";
|
import { createFileObservationSource } from "./editor/observation-source";
|
||||||
import { FileEditorView } from "./editor/view";
|
import { FileEditorView } from "./editor/view";
|
||||||
|
import { FileSourceView } from "./source/view";
|
||||||
import type { FileConflictAlertState } from "./conflict-alert";
|
import type { FileConflictAlertState } from "./conflict-alert";
|
||||||
import type { LiveFileModel } from "./live-file/model";
|
import type { LiveFileModel } from "./live-file/model";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
@@ -49,13 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
|
|
||||||
interface CodeLineProps {
|
|
||||||
tokens: HighlightToken[];
|
|
||||||
lineNumber: number;
|
|
||||||
gutterWidth: number;
|
|
||||||
highlighted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilePreviewBodyProps {
|
interface FilePreviewBodyProps {
|
||||||
preview: ExplorerFile | null;
|
preview: ExplorerFile | null;
|
||||||
mode?: "preview" | "source";
|
mode?: "preview" | "source";
|
||||||
@@ -76,11 +65,6 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileLineSelection {
|
|
||||||
lineStart: number;
|
|
||||||
lineEnd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize({ size }: { size: number }): string {
|
function formatFileSize({ size }: { size: number }): string {
|
||||||
if (size < 1024) {
|
if (size < 1024) {
|
||||||
return `${size} B`;
|
return `${size} B`;
|
||||||
@@ -91,101 +75,66 @@ function formatFileSize({ size }: { size: number }): string {
|
|||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampLineSelection(input: {
|
function ReadonlySource({
|
||||||
lineStart?: number;
|
preview,
|
||||||
lineEnd?: number;
|
filename,
|
||||||
lineCount: number;
|
location,
|
||||||
}): FileLineSelection | null {
|
navigationRevision,
|
||||||
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
|
}: {
|
||||||
return null;
|
preview: ExplorerFile;
|
||||||
}
|
filename: string;
|
||||||
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
|
location: WorkspaceFileLocation;
|
||||||
const rawLineEnd =
|
navigationRevision: number;
|
||||||
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
|
}) {
|
||||||
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
|
const theme = UnistylesRuntime.getTheme();
|
||||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
const { t } = useTranslation();
|
||||||
}
|
const visualTheme = useMemo(
|
||||||
|
() => ({
|
||||||
const CodeLine = React.memo(function CodeLine({
|
colorScheme: theme.colorScheme,
|
||||||
tokens,
|
background: theme.colors.surface0,
|
||||||
lineNumber,
|
foreground: theme.colors.foreground,
|
||||||
gutterWidth,
|
cursor: theme.colors.terminal.cursor,
|
||||||
highlighted,
|
foregroundMuted: theme.colors.foregroundMuted,
|
||||||
}: CodeLineProps) {
|
border: theme.colors.border,
|
||||||
const gutterStyle = useMemo(
|
selection: theme.colors.terminal.selectionBackground,
|
||||||
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
|
monoFont: theme.fontFamily.mono,
|
||||||
[gutterWidth],
|
codeFontSize: theme.fontSize.code,
|
||||||
);
|
syntax: theme.colors.syntax,
|
||||||
const lineStyle = useMemo(
|
}),
|
||||||
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
|
[theme],
|
||||||
[highlighted],
|
|
||||||
);
|
|
||||||
const keyedTokens = useMemo(
|
|
||||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
|
||||||
[tokens],
|
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={lineStyle}>
|
<FileSourceView
|
||||||
<View style={gutterStyle}>
|
content={preview.content ?? ""}
|
||||||
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
filename={filename}
|
||||||
{String(lineNumber)}
|
location={location}
|
||||||
</Text>
|
navigationRevision={navigationRevision}
|
||||||
</View>
|
size={preview.size}
|
||||||
<Text selectable style={codeLineStyles.lineText}>
|
theme={visualTheme}
|
||||||
{keyedTokens.map(({ key, token }) => (
|
tooLargeMessage={t("panels.file.tooLargeToDisplay")}
|
||||||
<CodeLineToken key={key} token={token} />
|
/>
|
||||||
))}
|
);
|
||||||
</Text>
|
}
|
||||||
|
|
||||||
|
function TooLargeSource({ size }: { size?: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View style={styles.centerState} testID="file-source-too-large">
|
||||||
|
<Text style={styles.emptyText}>{t("panels.file.tooLargeToDisplay")}</Text>
|
||||||
|
{size ? <Text style={styles.binaryMetaText}>{formatFileSize({ size })}</Text> : null}
|
||||||
</View>
|
</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({
|
function FilePreviewBody({
|
||||||
preview,
|
preview,
|
||||||
mode,
|
mode,
|
||||||
isLoading,
|
isLoading,
|
||||||
isMobile,
|
isMobile: _isMobile,
|
||||||
location,
|
location,
|
||||||
navigationRevision,
|
navigationRevision,
|
||||||
imagePreviewUri,
|
imagePreviewUri,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const theme = UnistylesRuntime.getTheme();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const filePath = location.path;
|
const filePath = location.path;
|
||||||
// A line target means the caller wants to land on that line, so fall back to
|
// A line target means the caller wants to land on that line, so fall back to
|
||||||
@@ -197,48 +146,11 @@ function FilePreviewBody({
|
|||||||
|
|
||||||
const previewScrollRef = useRef<RNScrollView>(null);
|
const previewScrollRef = useRef<RNScrollView>(null);
|
||||||
|
|
||||||
const highlightedLines = useMemo(() => {
|
|
||||||
if (!preview || preview.kind !== "text" || renderKind) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return highlightCode(preview.content ?? "", filePath);
|
|
||||||
}, [renderKind, 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(
|
const imageSource = useMemo(
|
||||||
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
||||||
[imagePreviewUri],
|
[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, navigationRevision]);
|
|
||||||
|
|
||||||
if (isLoading && !preview) {
|
if (isLoading && !preview) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centerState} testID="file-preview-loading">
|
<View style={styles.centerState} testID="file-preview-loading">
|
||||||
@@ -280,51 +192,13 @@ function FilePreviewBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<View style={styles.previewScrollContainer}>
|
<ReadonlySource
|
||||||
<RNScrollView
|
preview={preview}
|
||||||
ref={previewScrollRef}
|
filename={filePath}
|
||||||
style={styles.previewContent}
|
location={location}
|
||||||
showsVerticalScrollIndicator
|
navigationRevision={navigationRevision}
|
||||||
>
|
/>
|
||||||
{isMobile ? (
|
|
||||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
|
||||||
) : (
|
|
||||||
<RNScrollView
|
|
||||||
horizontal
|
|
||||||
nestedScrollEnabled
|
|
||||||
showsHorizontalScrollIndicator
|
|
||||||
contentContainerStyle={styles.previewCodeScrollContent}
|
|
||||||
>
|
|
||||||
{codeLines}
|
|
||||||
</RNScrollView>
|
|
||||||
)}
|
|
||||||
</RNScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,6 +431,13 @@ function FilePanePresentation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
|
if (errorMessage === "File is too large to display") {
|
||||||
|
return (
|
||||||
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
|
<TooLargeSource />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="workspace-file-pane">
|
<View style={styles.container} testID="workspace-file-pane">
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SOURCE_PRESENTATION_BUDGETS, selectSourcePresentation } from "./presentation";
|
||||||
|
|
||||||
|
describe("selectSourcePresentation", () => {
|
||||||
|
for (const platform of ["web", "native"] as const) {
|
||||||
|
const budgets = SOURCE_PRESENTATION_BUDGETS[platform];
|
||||||
|
it(`keeps ${platform} threshold edges predictable`, () => {
|
||||||
|
expect(selectSourcePresentation({ platform, size: budgets.highlighted })).toBe("highlighted");
|
||||||
|
expect(selectSourcePresentation({ platform, size: budgets.highlighted + 1 })).toBe("plain");
|
||||||
|
expect(selectSourcePresentation({ platform, size: budgets.plain })).toBe("plain");
|
||||||
|
expect(selectSourcePresentation({ platform, size: budgets.plain + 1 })).toBe("unsupported");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const SOURCE_PRESENTATION_BUDGETS = {
|
||||||
|
web: { highlighted: 10 * 1024 * 1024, plain: 50 * 1024 * 1024 },
|
||||||
|
native: { highlighted: 1024 * 1024, plain: 10 * 1024 * 1024 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SourcePresentation = "highlighted" | "plain" | "unsupported";
|
||||||
|
export type SourcePlatform = keyof typeof SOURCE_PRESENTATION_BUDGETS;
|
||||||
|
|
||||||
|
export function selectSourcePresentation(input: {
|
||||||
|
size: number;
|
||||||
|
platform: SourcePlatform;
|
||||||
|
}): SourcePresentation {
|
||||||
|
const budgets = SOURCE_PRESENTATION_BUDGETS[input.platform];
|
||||||
|
if (input.size <= budgets.highlighted) return "highlighted";
|
||||||
|
if (input.size <= budgets.plain) return "plain";
|
||||||
|
return "unsupported";
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { FlatList, Text, View, type ListRenderItem } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||||
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||||
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
|
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||||
|
import { selectSourcePresentation } from "./presentation";
|
||||||
|
|
||||||
|
interface FileSourceViewProps {
|
||||||
|
content: string;
|
||||||
|
filename: string;
|
||||||
|
location: WorkspaceFileLocation;
|
||||||
|
navigationRevision: number;
|
||||||
|
size: number;
|
||||||
|
theme: EditorVisualTheme;
|
||||||
|
tooLargeMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceLine {
|
||||||
|
number: number;
|
||||||
|
tokens: HighlightToken[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileSourceView({
|
||||||
|
content,
|
||||||
|
filename,
|
||||||
|
location,
|
||||||
|
navigationRevision,
|
||||||
|
size,
|
||||||
|
tooLargeMessage,
|
||||||
|
}: FileSourceViewProps) {
|
||||||
|
const presentation = selectSourcePresentation({ size, platform: "native" });
|
||||||
|
if (presentation === "unsupported") {
|
||||||
|
return (
|
||||||
|
<View style={styles.unsupported} testID="file-source-too-large">
|
||||||
|
<Text style={styles.unsupportedText}>{tooLargeMessage}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<VirtualizedSource
|
||||||
|
content={content}
|
||||||
|
filename={filename}
|
||||||
|
location={location}
|
||||||
|
navigationRevision={navigationRevision}
|
||||||
|
presentation={presentation}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VirtualizedSource({
|
||||||
|
content,
|
||||||
|
filename,
|
||||||
|
location,
|
||||||
|
navigationRevision,
|
||||||
|
presentation,
|
||||||
|
}: Omit<FileSourceViewProps, "size" | "theme" | "tooLargeMessage"> & {
|
||||||
|
presentation: "highlighted" | "plain";
|
||||||
|
}) {
|
||||||
|
const listRef = useRef<FlatList<SourceLine>>(null);
|
||||||
|
const lines = useMemo(() => {
|
||||||
|
if (presentation === "highlighted")
|
||||||
|
return highlightCode(content, filename).map((tokens, index) => ({
|
||||||
|
number: index + 1,
|
||||||
|
tokens,
|
||||||
|
}));
|
||||||
|
return content
|
||||||
|
.split("\n")
|
||||||
|
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
|
||||||
|
}, [content, filename, presentation]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!location.lineStart) return;
|
||||||
|
listRef.current?.scrollToIndex({
|
||||||
|
index: Math.min(location.lineStart - 1, lines.length - 1),
|
||||||
|
animated: false,
|
||||||
|
viewPosition: 0.5,
|
||||||
|
});
|
||||||
|
}, [lines.length, location.lineStart, navigationRevision]);
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={lines}
|
||||||
|
keyExtractor={sourceLineKey}
|
||||||
|
initialNumToRender={24}
|
||||||
|
windowSize={9}
|
||||||
|
getItemLayout={sourceLineLayout}
|
||||||
|
renderItem={renderSourceLine}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceLineView({ line }: { line: SourceLine }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.line}>
|
||||||
|
<Text style={styles.gutter}>{line.number}</Text>
|
||||||
|
<Text selectable style={styles.text}>
|
||||||
|
{line.tokens.map((token) => (
|
||||||
|
<Text key={`${token.style}:${token.text}`} style={syntaxTokenStyleFor(token.style)}>
|
||||||
|
{token.text}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLineKey(line: SourceLine): string {
|
||||||
|
return String(line.number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLineLayout(_data: ArrayLike<SourceLine> | null | undefined, index: number) {
|
||||||
|
return { length: 20, offset: index * 20, index };
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSourceLine: ListRenderItem<SourceLine> = ({ item }) => <SourceLineView line={item} />;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
unsupported: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
},
|
||||||
|
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||||
|
line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 },
|
||||||
|
gutter: {
|
||||||
|
width: 56,
|
||||||
|
paddingRight: theme.spacing[3],
|
||||||
|
textAlign: "right",
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontFamily: theme.fontFamily.mono,
|
||||||
|
fontSize: theme.fontSize.code,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: theme.fontFamily.mono,
|
||||||
|
fontSize: theme.fontSize.code,
|
||||||
|
lineHeight: theme.fontSize.code * 1.45,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FileSourceView } from "./view.native";
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Compartment, EditorState } from "@codemirror/state";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { getLanguageForFile } from "@getpaseo/highlight";
|
||||||
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
|
import type { EditorVisualTheme } from "../editor/extensions.web";
|
||||||
|
import { editorTheme } from "../editor/extensions.web";
|
||||||
|
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
|
||||||
|
|
||||||
|
interface FileSourceViewProps {
|
||||||
|
content: string;
|
||||||
|
filename: string;
|
||||||
|
location: WorkspaceFileLocation;
|
||||||
|
navigationRevision: number;
|
||||||
|
size: number;
|
||||||
|
theme: EditorVisualTheme;
|
||||||
|
tooLargeMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageCompartment = new Compartment();
|
||||||
|
const themeCompartment = new Compartment();
|
||||||
|
|
||||||
|
export function FileSourceView({
|
||||||
|
content,
|
||||||
|
filename,
|
||||||
|
location,
|
||||||
|
navigationRevision,
|
||||||
|
size,
|
||||||
|
theme,
|
||||||
|
tooLargeMessage,
|
||||||
|
}: FileSourceViewProps) {
|
||||||
|
const presentation = selectSourcePresentation({ size, platform: "web" });
|
||||||
|
if (presentation === "unsupported") {
|
||||||
|
return (
|
||||||
|
<div data-testid="file-source-too-large" style={UNSUPPORTED_STYLE}>
|
||||||
|
{tooLargeMessage}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ReadonlyCodeMirror
|
||||||
|
content={content}
|
||||||
|
filename={filename}
|
||||||
|
location={location}
|
||||||
|
navigationRevision={navigationRevision}
|
||||||
|
presentation={presentation}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadonlyCodeMirror({
|
||||||
|
content,
|
||||||
|
filename,
|
||||||
|
location,
|
||||||
|
navigationRevision,
|
||||||
|
presentation,
|
||||||
|
theme,
|
||||||
|
}: Omit<FileSourceViewProps, "size" | "tooLargeMessage"> & {
|
||||||
|
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||||
|
}) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const initial = useRef({ content, filename, presentation, theme });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current) return;
|
||||||
|
const values = initial.current;
|
||||||
|
const view = new EditorView({
|
||||||
|
parent: hostRef.current,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: values.content,
|
||||||
|
extensions: [
|
||||||
|
EditorState.readOnly.of(true),
|
||||||
|
EditorView.editable.of(false),
|
||||||
|
languageCompartment.of(
|
||||||
|
languageFor({ filename: values.filename, presentation: values.presentation }),
|
||||||
|
),
|
||||||
|
themeCompartment.of(editorTheme(values.theme)),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
viewRef.current = view;
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || view.state.doc.toString() === content) return;
|
||||||
|
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } });
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewRef.current?.dispatch({
|
||||||
|
effects: [
|
||||||
|
languageCompartment.reconfigure(languageFor({ filename, presentation })),
|
||||||
|
themeCompartment.reconfigure(editorTheme(theme)),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}, [filename, presentation, theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || !location.lineStart) return;
|
||||||
|
const line = Math.min(location.lineStart, view.state.doc.lines);
|
||||||
|
const from = view.state.doc.line(line).from;
|
||||||
|
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
|
||||||
|
}, [location.lineStart, navigationRevision]);
|
||||||
|
|
||||||
|
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageFor(input: {
|
||||||
|
filename: string;
|
||||||
|
presentation: Exclude<SourcePresentation, "unsupported">;
|
||||||
|
}) {
|
||||||
|
return input.presentation === "highlighted"
|
||||||
|
? (getLanguageForFile(input.filename)?.extension ?? [])
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||||
|
const UNSUPPORTED_STYLE = {
|
||||||
|
alignItems: "center",
|
||||||
|
display: "flex",
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
} as const;
|
||||||
@@ -1700,6 +1700,7 @@ export const ar: TranslationResources = {
|
|||||||
loading: "جارٍ تحميل الملف...",
|
loading: "جارٍ تحميل الملف...",
|
||||||
noPreview: "لا تتوفر معاينة",
|
noPreview: "لا تتوفر معاينة",
|
||||||
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
binaryPreviewUnavailable: "المعاينة الثنائية غير متاحة",
|
||||||
|
tooLargeToDisplay: "هذا الملف كبير جدًا بحيث لا يمكن عرضه",
|
||||||
failedToLoad: "فشل تحميل الملف",
|
failedToLoad: "فشل تحميل الملف",
|
||||||
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
failedToLoadPreview: "فشل تحميل معاينة الملف",
|
||||||
editor: {
|
editor: {
|
||||||
|
|||||||
@@ -1710,6 +1710,7 @@ export const en = {
|
|||||||
loading: "Loading file...",
|
loading: "Loading file...",
|
||||||
noPreview: "No preview available",
|
noPreview: "No preview available",
|
||||||
binaryPreviewUnavailable: "Binary preview unavailable",
|
binaryPreviewUnavailable: "Binary preview unavailable",
|
||||||
|
tooLargeToDisplay: "This file is too large to display",
|
||||||
failedToLoad: "Failed to load file",
|
failedToLoad: "Failed to load file",
|
||||||
failedToLoadPreview: "Failed to load file preview",
|
failedToLoadPreview: "Failed to load file preview",
|
||||||
editor: {
|
editor: {
|
||||||
|
|||||||
@@ -1746,6 +1746,7 @@ export const es: TranslationResources = {
|
|||||||
loading: "Cargando archivo...",
|
loading: "Cargando archivo...",
|
||||||
noPreview: "No hay vista previa disponible",
|
noPreview: "No hay vista previa disponible",
|
||||||
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
binaryPreviewUnavailable: "Vista previa binaria no disponible",
|
||||||
|
tooLargeToDisplay: "Este archivo es demasiado grande para mostrarlo",
|
||||||
failedToLoad: "No se pudo cargar el archivo",
|
failedToLoad: "No se pudo cargar el archivo",
|
||||||
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
|
||||||
editor: {
|
editor: {
|
||||||
|
|||||||
@@ -1750,6 +1750,7 @@ export const fr: TranslationResources = {
|
|||||||
loading: "Chargement du fichier...",
|
loading: "Chargement du fichier...",
|
||||||
noPreview: "Aucun aperçu disponible",
|
noPreview: "Aucun aperçu disponible",
|
||||||
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
binaryPreviewUnavailable: "Aperçu binaire indisponible",
|
||||||
|
tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché",
|
||||||
failedToLoad: "Échec du chargement du fichier",
|
failedToLoad: "Échec du chargement du fichier",
|
||||||
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
|
||||||
editor: {
|
editor: {
|
||||||
|
|||||||
@@ -1717,6 +1717,7 @@ export const ja: TranslationResources = {
|
|||||||
loading: "ファイルを読み込み中...",
|
loading: "ファイルを読み込み中...",
|
||||||
noPreview: "プレビューが利用できません",
|
noPreview: "プレビューが利用できません",
|
||||||
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
binaryPreviewUnavailable: "バイナリプレビューが利用できません",
|
||||||
|
tooLargeToDisplay: "このファイルは大きすぎて表示できません",
|
||||||
failedToLoad: "ファイルの読み込みに失敗しました",
|
failedToLoad: "ファイルの読み込みに失敗しました",
|
||||||
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
|
||||||
editor: {
|
editor: {
|
||||||
|
|||||||
@@ -1710,6 +1710,7 @@ export const ko: TranslationResources = {
|
|||||||
loading: "파일 불러오는 중...",
|
loading: "파일 불러오는 중...",
|
||||||
noPreview: "사용 가능한 미리보기가 없습니다",
|
noPreview: "사용 가능한 미리보기가 없습니다",
|
||||||
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
|
binaryPreviewUnavailable: "바이너리 미리보기를 사용할 수 없습니다",
|
||||||
|
tooLargeToDisplay: "이 파일은 너무 커서 표시할 수 없습니다",
|
||||||
failedToLoad: "파일을 불러오지 못했습니다",
|
failedToLoad: "파일을 불러오지 못했습니다",
|
||||||
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
failedToLoadPreview: "파일 미리보기를 불러오지 못했습니다",
|
||||||
editor: {
|
editor: {
|
||||||
|
|||||||
@@ -1732,6 +1732,7 @@ export const ptBR: TranslationResources = {
|
|||||||
loading: "Carregando arquivo...",
|
loading: "Carregando arquivo...",
|
||||||
noPreview: "Nenhuma prévia disponível",
|
noPreview: "Nenhuma prévia disponível",
|
||||||
binaryPreviewUnavailable: "Prévia binária indisponível",
|
binaryPreviewUnavailable: "Prévia binária indisponível",
|
||||||
|
tooLargeToDisplay: "Este arquivo é grande demais para exibir",
|
||||||
failedToLoad: "Falha ao carregar arquivo",
|
failedToLoad: "Falha ao carregar arquivo",
|
||||||
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
|
||||||
editor: {
|
editor: {
|
||||||
|
|||||||
@@ -1730,6 +1730,7 @@ export const ru: TranslationResources = {
|
|||||||
loading: "Загрузка файла...",
|
loading: "Загрузка файла...",
|
||||||
noPreview: "Предварительный просмотр недоступен",
|
noPreview: "Предварительный просмотр недоступен",
|
||||||
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
binaryPreviewUnavailable: "Предварительный просмотр двоичного файла недоступен.",
|
||||||
|
tooLargeToDisplay: "Этот файл слишком велик для отображения",
|
||||||
failedToLoad: "Не удалось загрузить файл",
|
failedToLoad: "Не удалось загрузить файл",
|
||||||
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
|
||||||
editor: {
|
editor: {
|
||||||
|
|||||||
@@ -1680,6 +1680,7 @@ export const zhCN: TranslationResources = {
|
|||||||
loading: "正在加载文件...",
|
loading: "正在加载文件...",
|
||||||
noPreview: "没有可用预览",
|
noPreview: "没有可用预览",
|
||||||
binaryPreviewUnavailable: "二进制预览不可用",
|
binaryPreviewUnavailable: "二进制预览不可用",
|
||||||
|
tooLargeToDisplay: "此文件过大,无法显示",
|
||||||
failedToLoad: "加载文件失败",
|
failedToLoad: "加载文件失败",
|
||||||
failedToLoadPreview: "加载文件预览失败",
|
failedToLoadPreview: "加载文件预览失败",
|
||||||
editor: {
|
editor: {
|
||||||
|
|||||||
@@ -2212,6 +2212,61 @@ test("readFile resolves from binary file frames when the daemon supports them",
|
|||||||
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("readFile drops an old daemon's over-budget binary chunks and reports the refusal", async () => {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_file_budget_compat",
|
||||||
|
logger: createMockLogger(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const responsePromise = client.readFile("/tmp/project", "large.txt", "req-budget", 10);
|
||||||
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
path: "large.txt",
|
||||||
|
mode: "file",
|
||||||
|
acceptBinary: true,
|
||||||
|
maxBytes: 10,
|
||||||
|
requestId: "req-budget",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileBegin,
|
||||||
|
requestId: "req-budget",
|
||||||
|
metadata: {
|
||||||
|
mime: "text/plain",
|
||||||
|
size: 100,
|
||||||
|
encoding: "utf-8",
|
||||||
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeFileTransferFrame({
|
||||||
|
opcode: FileTransferOpcode.FileChunk,
|
||||||
|
requestId: "req-budget",
|
||||||
|
payload: new Uint8Array(100),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.triggerMessage(
|
||||||
|
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-budget" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(responsePromise).rejects.toThrow("File is too large to display");
|
||||||
|
});
|
||||||
|
|
||||||
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -831,6 +831,7 @@ interface CorrelatedResponseIdentity {
|
|||||||
interface PendingBinaryFileRead {
|
interface PendingBinaryFileRead {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
maxBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BinaryFileTransferState extends PendingBinaryFileRead {
|
interface BinaryFileTransferState extends PendingBinaryFileRead {
|
||||||
@@ -4308,6 +4309,7 @@ export class DaemonClient {
|
|||||||
mode: "list" | "file",
|
mode: "list" | "file",
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
acceptBinary = false,
|
acceptBinary = false,
|
||||||
|
maxBytes?: number,
|
||||||
): Promise<FileExplorerPayload> {
|
): Promise<FileExplorerPayload> {
|
||||||
return this.sendCorrelatedSessionRequest({
|
return this.sendCorrelatedSessionRequest({
|
||||||
requestId,
|
requestId,
|
||||||
@@ -4317,6 +4319,7 @@ export class DaemonClient {
|
|||||||
path,
|
path,
|
||||||
mode,
|
mode,
|
||||||
...(acceptBinary ? { acceptBinary: true } : {}),
|
...(acceptBinary ? { acceptBinary: true } : {}),
|
||||||
|
...(maxBytes ? { maxBytes } : {}),
|
||||||
},
|
},
|
||||||
responseType: "file_explorer_response",
|
responseType: "file_explorer_response",
|
||||||
});
|
});
|
||||||
@@ -4337,11 +4340,23 @@ export class DaemonClient {
|
|||||||
return payload.directory;
|
return payload.directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFile(cwd: string, path: string, requestId?: string): Promise<FileReadResult> {
|
async readFile(
|
||||||
|
cwd: string,
|
||||||
|
path: string,
|
||||||
|
requestId?: string,
|
||||||
|
maxBytes?: number,
|
||||||
|
): Promise<FileReadResult> {
|
||||||
const resolvedRequestId = this.createRequestId(requestId);
|
const resolvedRequestId = this.createRequestId(requestId);
|
||||||
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path });
|
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
|
||||||
try {
|
try {
|
||||||
const payload = await this.requestFileExplorer(cwd, path, "file", resolvedRequestId, true);
|
const payload = await this.requestFileExplorer(
|
||||||
|
cwd,
|
||||||
|
path,
|
||||||
|
"file",
|
||||||
|
resolvedRequestId,
|
||||||
|
true,
|
||||||
|
maxBytes,
|
||||||
|
);
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
@@ -5761,10 +5776,33 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (frame.opcode === FileTransferOpcode.FileChunk) {
|
if (frame.opcode === FileTransferOpcode.FileChunk) {
|
||||||
|
// COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0.
|
||||||
|
// Old daemons stream despite maxBytes; discard before client-side accumulation.
|
||||||
|
if (transfer.maxBytes && transfer.size > transfer.maxBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
transfer.chunks.push(frame.payload);
|
transfer.chunks.push(frame.payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0.
|
||||||
|
if (transfer.maxBytes && transfer.size > transfer.maxBytes) {
|
||||||
|
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||||
|
this.handleSessionMessage({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: {
|
||||||
|
cwd: transfer.cwd,
|
||||||
|
path: transfer.path,
|
||||||
|
mode: "file",
|
||||||
|
directory: null,
|
||||||
|
file: null,
|
||||||
|
error: "File is too large to display",
|
||||||
|
requestId: frame.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
||||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||||
this.completedBinaryFileReads.set(frame.requestId, {
|
this.completedBinaryFileReads.set(frame.requestId, {
|
||||||
|
|||||||
@@ -2555,6 +2555,7 @@ export const FileExplorerRequestSchema = z.object({
|
|||||||
mode: z.enum(["list", "file"]),
|
mode: z.enum(["list", "file"]),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
acceptBinary: z.boolean().optional(),
|
acceptBinary: z.boolean().optional(),
|
||||||
|
maxBytes: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FileVersionSchema = z.discriminatedUnion("status", [
|
export const FileVersionSchema = z.discriminatedUnion("status", [
|
||||||
|
|||||||
@@ -372,6 +372,30 @@ describe("WorkspaceFilesSession", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects an over-budget file before opening a binary transfer", async () => {
|
||||||
|
const cwd = makeDir("workspace-files-read-budget-");
|
||||||
|
writeFileSync(join(cwd, "notes.txt"), "hello world");
|
||||||
|
const { subsystem, emitted, binary } = makeSubsystem({ hasBinaryChannel: true });
|
||||||
|
|
||||||
|
await subsystem.handleFileExplorerRequest({
|
||||||
|
type: "file_explorer_request",
|
||||||
|
cwd,
|
||||||
|
path: "notes.txt",
|
||||||
|
mode: "file",
|
||||||
|
requestId: "req-read-budget",
|
||||||
|
acceptBinary: true,
|
||||||
|
maxBytes: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(binary).toEqual([]);
|
||||||
|
expect(emitted).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: expect.objectContaining({ error: "File is too large to display" }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("streams a real file larger than the socket limit as paced ordered chunks", async () => {
|
test("streams a real file larger than the socket limit as paced ordered chunks", async () => {
|
||||||
const cwd = makeDir("workspace-files-large-binary-");
|
const cwd = makeDir("workspace-files-large-binary-");
|
||||||
const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123);
|
const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123);
|
||||||
|
|||||||
@@ -263,6 +263,12 @@ export class WorkspaceFilesSession {
|
|||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
if (request.maxBytes) {
|
||||||
|
const file = await getDownloadableFileInfo({ root: cwd, relativePath: requestedPath });
|
||||||
|
if (file.size > request.maxBytes) {
|
||||||
|
throw new Error("File is too large to display");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (request.acceptBinary && this.host.hasBinaryChannel()) {
|
if (request.acceptBinary && this.host.hasBinaryChannel()) {
|
||||||
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
|
await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
|
||||||
await this.host.emitBinary(
|
await this.host.emitBinary(
|
||||||
|
|||||||
Reference in New Issue
Block a user