Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andThePharmer 457ddad8cc fix(app): preserve highlighting within Android span limit
Raise the native row cutoff to 51, derived from the five span operations per source token. Cover the boundary and an ordinary TypeScript statement that lost colors at the previous cutoff.

Co-authored-by: ThePharmer <metastallion@gmail.com>
2026-10-10 12:17:58 +02:00
paseo-bot[bot]andThePharmer bb5c1ffbbc 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>
2026-10-09 16:32:01 +02:00
3 changed files with 122 additions and 16 deletions
@@ -0,0 +1,88 @@
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: 25 }, (_, i) => i))} `;
const content = `${dense}\n{"ok":true}\n`;
const original = highlightCode(content, "data.json");
expect(original[0]).toHaveLength(52);
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: 25 }, (_, i) => i));
const original = highlightCode(content, "data.json");
expect(original[0]).toHaveLength(51);
expect(
buildNativeSourceLines({ content, filename: "data.json", presentation: "highlighted" }),
).toEqual([{ number: 1, tokens: original[0] }]);
});
it("keeps ordinary TypeScript statements within the span budget highlighted", () => {
const content =
'const values = [{ id: 0, name: "item0" }, { id: 1, name: "item1" }, { id: 2, name: "item2" }];';
const original = highlightCode(content, "ordinary.ts");
expect(original[0]).toHaveLength(50);
expect(
buildNativeSourceLines({ content, filename: "ordinary.ts", 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 has 256 span-priority slots (indices 0–255). Each colored source
// token produces five operations: color, size, font family, line height and
// tag. 51 tokens use 255 operations; the 52nd exceeds the priority limit.
const MAX_HIGHLIGHTED_TOKENS_PER_LINE = 51;
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({