fix: pretty-print JSON file previews (#1206)

* fix: pretty-print JSON file previews

* fix: avoid formatting JSON with unsafe numbers

* fix: preserve precision-sensitive JSON previews

* fix: preserve signed zero in JSON previews

* fix: scan JSON numbers without repeated slicing

---------

Co-authored-by: Kael S <YOUR_GITHUB_EMAIL_HERE>
This commit is contained in:
Kaelz31 2026-05-11 08:52:55 -04:00 committed by GitHub
parent a0316d2599
commit 3524a43d18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 241 additions and 4 deletions

View file

@ -5640,7 +5640,11 @@ function TextViewer({
}
}
const lineCount = text ? text.split('\n').length : 0;
const displayText = useMemo(
() => (text == null ? null : formatJsonFileTextForDisplay(file, text)),
[file.name, file.mime, text],
);
const lineCount = displayText ? displayText.split('\n').length : 0;
return (
<div className="viewer text-viewer">
@ -5679,16 +5683,119 @@ function TextViewer({
<div className="viewer-body">
{text === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : lineCount > 0 ? (
<CodeWithLines text={text} />
) : displayText !== null && lineCount > 0 ? (
<CodeWithLines text={displayText} />
) : (
<pre className="viewer-source">{text}</pre>
<pre className="viewer-source">{displayText}</pre>
)}
</div>
</div>
);
}
function formatJsonFileTextForDisplay(file: ProjectFile, text: string): string {
if (!isJsonFile(file)) return text;
try {
if (hasPrecisionSensitiveJsonNumberText(text)) return text;
const parsed = JSON.parse(text) as unknown;
if (hasUnsafeJsonNumber(parsed)) return text;
return JSON.stringify(parsed, null, 2);
} catch {
return text;
}
}
function hasPrecisionSensitiveJsonNumberText(text: string): boolean {
let inString = false;
let escaped = false;
const numberTokenPattern = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
for (let i = 0; i < text.length;) {
const char = text[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = false;
}
i += 1;
continue;
}
if (char === '"') {
inString = true;
i += 1;
continue;
}
numberTokenPattern.lastIndex = i;
const match = numberTokenPattern.exec(text);
if (!match) {
i += 1;
continue;
}
const token = match[0];
if (isSignedNegativeZeroJsonNumberToken(token)) return true;
if (/[.eE]/.test(token) && isPrecisionSensitiveJsonNumberToken(token)) return true;
i = numberTokenPattern.lastIndex;
}
return false;
}
function isSignedNegativeZeroJsonNumberToken(token: string): boolean {
return /^-0(?:\.0+)?(?:[eE][+-]?\d+)?$/.test(token);
}
function isPrecisionSensitiveJsonNumberToken(token: string): boolean {
const parsed = Number(token);
if (!Number.isFinite(parsed)) return true;
const rendered = JSON.stringify(parsed);
if (!rendered) return true;
const originalValue = parseJsonNumberTokenAsDecimal(token);
const renderedValue = parseJsonNumberTokenAsDecimal(rendered);
return (
!originalValue ||
!renderedValue ||
originalValue.coefficient !== renderedValue.coefficient ||
originalValue.exponent !== renderedValue.exponent
);
}
function parseJsonNumberTokenAsDecimal(token: string): { coefficient: bigint; exponent: number } | null {
const match = /^(-)?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(token);
if (!match) return null;
const [, sign, integerPart, fractionPart = '', exponentPart = '0'] = match;
const coefficient = BigInt(`${sign ?? ''}${integerPart}${fractionPart}`);
const exponent = Number(exponentPart) - fractionPart.length;
return normalizeDecimalParts(coefficient, exponent);
}
function normalizeDecimalParts(coefficient: bigint, exponent: number): { coefficient: bigint; exponent: number } {
if (coefficient === 0n) return { coefficient: 0n, exponent: 0 };
let normalizedCoefficient = coefficient;
let normalizedExponent = exponent;
while (normalizedCoefficient % 10n === 0n) {
normalizedCoefficient /= 10n;
normalizedExponent += 1;
}
return { coefficient: normalizedCoefficient, exponent: normalizedExponent };
}
function hasUnsafeJsonNumber(value: unknown): boolean {
if (typeof value === 'number') {
return !Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value));
}
if (Array.isArray(value)) return value.some(hasUnsafeJsonNumber);
if (value && typeof value === 'object') return Object.values(value).some(hasUnsafeJsonNumber);
return false;
}
function isJsonFile(file: ProjectFile): boolean {
return file.name.toLowerCase().endsWith('.json') || file.mime.toLowerCase().startsWith('application/json');
}
function MarkdownViewer({
projectId,
file,

View file

@ -59,6 +59,136 @@ function deferredResponse() {
return { promise, resolve };
}
describe('FileViewer JSON artifacts', () => {
it('pretty-prints valid JSON in the text viewer', async () => {
const file = baseFile({
name: 'data.json',
path: 'data.json',
kind: 'code',
mime: 'application/json',
});
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url === '/api/projects/project-1/raw/data.json') {
return new Response('{"title":"Launch Metrics","stats":{"views":42,"active":true}}');
}
return new Response('', { status: 404 });
}));
const { container } = render(<FileViewer projectId="project-1" file={file} />);
await waitFor(() => {
expect(container.querySelector('.lines')?.textContent).toBe(
'{\n "title": "Launch Metrics",\n "stats": {\n "views": 42,\n "active": true\n }\n}',
);
});
});
it('keeps raw JSON when pretty-printing would round an unsafe integer', async () => {
const file = baseFile({
name: 'data.json',
path: 'data.json',
kind: 'code',
mime: 'application/json',
});
const rawJson = '{"id":9007199254740993,"name":"large"}';
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url === '/api/projects/project-1/raw/data.json') {
return new Response(rawJson);
}
return new Response('', { status: 404 });
}));
const { container } = render(<FileViewer projectId="project-1" file={file} />);
await waitFor(() => {
const displayedText = container.querySelector('.lines')?.textContent ?? '';
expect(displayedText).toBe(rawJson);
expect(displayedText).toContain('9007199254740993');
expect(displayedText).not.toContain('9007199254740992');
});
});
it('keeps raw JSON when pretty-printing would round a high-precision decimal', async () => {
const file = baseFile({
name: 'data.json',
path: 'data.json',
kind: 'code',
mime: 'application/json',
});
const rawJson = '{"ratio":0.1234567890123456789,"name":"precise"}';
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url === '/api/projects/project-1/raw/data.json') {
return new Response(rawJson);
}
return new Response('', { status: 404 });
}));
const { container } = render(<FileViewer projectId="project-1" file={file} />);
await waitFor(() => {
const displayedText = container.querySelector('.lines')?.textContent ?? '';
expect(displayedText).toBe(rawJson);
expect(displayedText).toContain('0.1234567890123456789');
expect(displayedText).not.toContain('0.12345678901234568');
});
});
it('keeps raw JSON when pretty-printing would round a high-precision exponent', async () => {
const file = baseFile({
name: 'data.json',
path: 'data.json',
kind: 'code',
mime: 'application/json',
});
const rawJson = '{"ratio":1.234567890123456789e2,"name":"precise"}';
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url === '/api/projects/project-1/raw/data.json') {
return new Response(rawJson);
}
return new Response('', { status: 404 });
}));
const { container } = render(<FileViewer projectId="project-1" file={file} />);
await waitFor(() => {
const displayedText = container.querySelector('.lines')?.textContent ?? '';
expect(displayedText).toBe(rawJson);
expect(displayedText).toContain('1.234567890123456789e2');
expect(displayedText).not.toContain('123.45678901234568');
});
});
it('keeps raw JSON when pretty-printing would erase signed negative zero', async () => {
const file = baseFile({
name: 'data.json',
path: 'data.json',
kind: 'code',
mime: 'application/json',
});
const rawJson = '{"delta":-0}';
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url === '/api/projects/project-1/raw/data.json') {
return new Response(rawJson);
}
return new Response('', { status: 404 });
}));
const { container } = render(<FileViewer projectId="project-1" file={file} />);
await waitFor(() => {
const displayedText = container.querySelector('.lines')?.textContent ?? '';
expect(displayedText).toBe(rawJson);
expect(displayedText).toContain('-0');
expect(displayedText).not.toContain('{"delta":0}');
});
});
});
describe('FileViewer SVG artifacts', () => {
it('routes SVG artifacts to the SVG viewer instead of the generic image viewer', () => {
const file = baseFile({