From bb5c1ffbbc2579b90ac4e1840e03d9bc68e4b594 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" Date: Fri, 9 Oct 2026 16:32:01 +0200 Subject: [PATCH] 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 --- .../src/file-pane/source/native-lines.test.ts | 78 +++++++++++++++++++ .../app/src/file-pane/source/native-lines.ts | 29 +++++++ .../app/src/file-pane/source/view.native.tsx | 21 ++--- 3 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 packages/app/src/file-pane/source/native-lines.test.ts create mode 100644 packages/app/src/file-pane/source/native-lines.ts diff --git a/packages/app/src/file-pane/source/native-lines.test.ts b/packages/app/src/file-pane/source/native-lines.test.ts new file mode 100644 index 0000000000..0e3bdc7265 --- /dev/null +++ b/packages/app/src/file-pane/source/native-lines.test.ts @@ -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(""); +} diff --git a/packages/app/src/file-pane/source/native-lines.ts b/packages/app/src/file-pane/source/native-lines.ts new file mode 100644 index 0000000000..5c8000910b --- /dev/null +++ b/packages/app/src/file-pane/source/native-lines.ts @@ -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 }], + })); +} diff --git a/packages/app/src/file-pane/source/view.native.tsx b/packages/app/src/file-pane/source/view.native.tsx index de7c5eeae1..78b6fe8e42 100644 --- a/packages/app/src/file-pane/source/view.native.tsx +++ b/packages/app/src/file-pane/source/view.native.tsx @@ -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>(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({