Skip to content
Source
Sign inSign up

source-file-document.ts

93 lines3.4 KB

1import type { HighlightedToken } from "../../lib/highlights";
2import { buildNativeSourceRows } from "./nativeSourceFileAdapter";
3​
4const MAX_CACHED_DOCUMENTS = 8;
5const MAX_CACHED_CHARACTERS = 4 * 1024 * 1024;
6​
7export interface SourceFileDocument {
8 readonly contents: string;
9 readonly lines: ReadonlyArray<string>;
10 readonly rowsJson: string;
12​
13const documentCache = new Map<string, SourceFileDocument>();
14let cachedCharacterCount = 0;
15​
16function removeOldestCachedDocument(): void {
17 const oldestKey = documentCache.keys().next().value;
18 if (typeof oldestKey !== "string") {
19 return;
20 }
21 const document = documentCache.get(oldestKey);
22 documentCache.delete(oldestKey);
23 cachedCharacterCount -= (document?.contents.length ?? 0) + (document?.rowsJson.length ?? 0);
25​
26export function prepareSourceFileDocument(contents: string): SourceFileDocument {
27 const cached = documentCache.get(contents);
28 if (cached !== undefined) {
29 documentCache.delete(contents);
30 documentCache.set(contents, cached);
31 return cached;
32 }
33​
34 const normalizedContents = contents.replace(/\r\n?/g, "\n");
35 const lines = normalizedContents.split("\n");
36 const document = {
37 contents: normalizedContents,
38 lines,
39 rowsJson: JSON.stringify(buildNativeSourceRows(lines)),
40 } satisfies SourceFileDocument;
41 const characterCount = document.contents.length + document.rowsJson.length;
42​
43 if (characterCount <= MAX_CACHED_CHARACTERS) {
44 while (
45 documentCache.size >= MAX_CACHED_DOCUMENTS ||
46 cachedCharacterCount + characterCount > MAX_CACHED_CHARACTERS
47 ) {
48 removeOldestCachedDocument();
49 }
50 documentCache.set(contents, document);
51 cachedCharacterCount += characterCount;
52 }
53​
54 return document;
56​
57// Virtualization prevents document-wide selection; cap spans and fall back to a fully selectable string.
58export function boundedSelectableSourceTokens(
59 tokens: ReadonlyArray<ReadonlyArray<HighlightedToken>> | null,
60): typeof tokens {
61 if (!tokens) return null;
62 let spans = tokens.length;
63 for (const line of tokens) {
64 spans += line.length;
65 if (spans > 2_000) return null;
66 }
67 return tokens;
69​
70export interface SelectableSourceHtmlStyle {
71 readonly foreground: string;
72 readonly selection: string;
73 readonly fontSize: number;
74 readonly rowHeight: number;
75 readonly wordBreak: boolean;
77​
78// A <pre> would drop a leading newline, so whitespace is preserved on a plain <div>.
79export function selectableSourceHtml(contents: string, style: SelectableSourceHtmlStyle): string {
80 const text = contents
81 .split("\n")
82 .map(
83 (line, index) =>
84 `<span id="L${index + 1}">${line.replace(/[&<>]/g, (char) =>
85 char === "&" ? "&amp;" : char === "<" ? "&lt;" : "&gt;",
86 )}</span>`,
87 )
88 .join("\n");
89 const wrap = style.wordBreak
90 ? "white-space:pre-wrap;overflow-wrap:anywhere"
91 : "white-space:pre;width:max-content";
92 return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none';style-src 'unsafe-inline'"><style>html,body{margin:0;background:transparent}div{box-sizing:border-box;padding:8px 12px ${style.rowHeight}px;color:${style.foreground};font-family:monospace;font-size:${style.fontSize}px;line-height:${style.rowHeight / style.fontSize};${wrap}}::selection{background:color-mix(in srgb,${style.selection} 32%,transparent)}</style></head><body><div>${text}</div></body></html>`;