fix(app): open dense source lines without freezing Android

Line virtualization did not bound the text spans inside a single row. Fall back to plain text for dense rows so opening or restoring minified JSON remains responsive.

Co-authored-by: ThePharmer <metastallion@gmail.com>
This commit is contained in:
paseo-bot[bot]
2026-10-09 16:32:01 +02:00
co-authored by ThePharmer
parent 718ed932b7
commit bb5c1ffbbc
3 changed files with 112 additions and 16 deletions
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { highlightCode } from "@getpaseo/highlight";
import { buildNativeSourceLines } from "./native-lines";
const records = Array.from({ length: 900 }, (_, i) => ({
t: `comment number ${i} with some text`,
l: i % 7,
r: i % 3,
u: `user${i}`,
}));
describe("native source lines", () => {
it("opens the reported single-line JSON without thousands of native text spans", () => {
const content = JSON.stringify(records);
expect(new TextEncoder().encode(content)).toHaveLength(60981);
expect(highlightCode(content, "one-line-repro.json")[0]).toHaveLength(14401);
const lines = buildNativeSourceLines({
content,
filename: "one-line-repro.json",
presentation: "highlighted",
});
expect(lines).toEqual([{ number: 1, tokens: [{ text: content, style: null }] }]);
});
it("keeps the pretty-printed control highlighted and preserves its line numbers", () => {
const content = JSON.stringify(records, null, 2);
const lines = buildNativeSourceLines({
content,
filename: "pretty-repro.json",
presentation: "highlighted",
});
expect(lines).toHaveLength(5402);
expect(lines.map((line) => line.tokens)).toEqual(highlightCode(content, "pretty-repro.json"));
expect(lines.at(-1)?.number).toBe(5402);
expect(lines.flatMap((line) => line.tokens).some((token) => token.style !== null)).toBe(true);
expect(lines.map(lineText).join("\n")).toBe(content);
});
it("falls back only for dense rows, without changing text or line numbering", () => {
const dense = JSON.stringify(Array.from({ length: 16 }, (_, i) => i));
const content = `${dense}\n{"ok":true}\n`;
const original = highlightCode(content, "data.json");
expect(original[0]).toHaveLength(33);
const lines = buildNativeSourceLines({
content,
filename: "data.json",
presentation: "highlighted",
});
expect(lines).toEqual([
{ number: 1, tokens: [{ text: dense, style: null }] },
{ number: 2, tokens: original[1] },
{ number: 3, tokens: original[2] },
]);
});
it("keeps a row at the span budget highlighted", () => {
const content = `${JSON.stringify(Array.from({ length: 15 }, (_, i) => i))} `;
const original = highlightCode(content, "data.json");
expect(original[0]).toHaveLength(32);
expect(
buildNativeSourceLines({ content, filename: "data.json", presentation: "highlighted" }),
).toEqual([{ number: 1, tokens: original[0] }]);
});
it("keeps the plain tier and unknown languages lossless", () => {
for (const presentation of ["highlighted", "plain"] as const) {
const content = "hello\r\n\nworld\n";
expect(buildNativeSourceLines({ content, filename: "data.unknown", presentation })).toEqual(
content
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] })),
);
}
});
});
function lineText(line: { tokens: { text: string }[] }): string {
return line.tokens.map((token) => token.text).join("");
}
@@ -0,0 +1,29 @@
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
// Android gives text spans 256 priority slots. A colored token also inherits
// size, font family and line height and gets a tag span, so leave room for
// those operations instead of mounting an unbounded text tree in one row.
const MAX_HIGHLIGHTED_TOKENS_PER_LINE = 32;
export interface SourceLine {
number: number;
tokens: HighlightToken[];
}
export function buildNativeSourceLines(input: {
content: string;
filename: string;
presentation: "highlighted" | "plain";
}): SourceLine[] {
const lines =
input.presentation === "highlighted"
? highlightCode(input.content, input.filename)
: input.content.split("\n").map((text) => [{ text, style: null }]);
return lines.map((tokens, index) => ({
number: index + 1,
tokens:
tokens.length <= MAX_HIGHLIGHTED_TOKENS_PER_LINE
? tokens
: [{ text: tokens.map((token) => token.text).join(""), style: null }],
}));
}
@@ -1,7 +1,7 @@
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 { buildNativeSourceLines, type SourceLine } from "./native-lines";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
@@ -17,11 +17,6 @@ interface FileSourceViewProps {
tooLargeMessage: string;
}
interface SourceLine {
number: number;
tokens: HighlightToken[];
}
export function FileSourceView({
content,
filename,
@@ -59,16 +54,10 @@ function VirtualizedSource({
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]);
const lines = useMemo(
() => buildNativeSourceLines({ content, filename, presentation }),
[content, filename, presentation],
);
useEffect(() => {
if (!location.lineStart) return;
listRef.current?.scrollToIndex({