返回 DeepSeek-Reasonix
LineNumberCode.tsx
根目录 / desktop / frontend / src / components / editors / LineNumberCode.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { useVirtualizer } from "@tanstack/react-virtual";
3 import type { EditorProps } from "../CodeViewer";
4 import { highlightToHtml, shouldHighlightSource } from "../../lib/highlight";
5 import { useT } from "../../lib/i18n";
6 import { CopyButton } from "../CopyButton";
7 import {
8 findCodeMatches,
9 MAX_REGEX_PATTERN_LENGTH,
10 MAX_REGEX_SOURCE_LENGTH,
11 MAX_SEARCH_MATCHES,
12 type CodeSearchMatch,
13 type CodeSearchResult,
14 type RegexSearchErrorCode,
15 } from "./codeSearch";
16 import { startRegexSearch } from "./regexSearchClient";
17
18 export { findCodeMatches, MAX_SEARCH_MATCHES } from "./codeSearch";
19
20 // Line-numbered code viewer with virtual scroll and viewer-scoped search.
21 const VIRTUAL_THRESHOLD = 100;
22 const ROW_HEIGHT_ESTIMATE = 22;
23 const OVERSCAN = 15;
24 const SEARCH_DEBOUNCE_MS = 100;
25 const EMPTY_SEARCH_RESULT: CodeSearchResult = { matches: [], truncated: false };
26
27 interface RegexSearchState {
28 source: string;
29 query: string;
30 caseSensitive: boolean;
31 wholeWord: boolean;
32 status: "idle" | "pending" | "ready" | "error";
33 result: CodeSearchResult;
34 error?: RegexSearchErrorCode;
35 detail?: string;
36 }
37
38 // Insert mark elements into one already-highlighted line. Search offsets stay
39 // relative to raw source, so escaped entities and token span boundaries remain
40 // intact without rebuilding the full file HTML on every keystroke.
41 export function highlightLineMatches(
42 highlightedLineHtml: string,
43 matches: CodeSearchMatch[],
44 currentMatch?: CodeSearchMatch,
45 ): string {
46 if (matches.length === 0) return highlightedLineHtml;
47
48 let htmlOffset = 0;
49 let sourceOffset = 0;
50 let matchIndex = 0;
51 let markOpen = false;
52 let result = "";
53
54 const openMark = () => (
55 matches[matchIndex]?.absoluteStart === currentMatch?.absoluteStart
56 ? '<mark class="code-search-hl code-search-hl--current">'
57 : '<mark class="code-search-hl">'
58 );
59
60 while (htmlOffset < highlightedLineHtml.length) {
61 const char = highlightedLineHtml[htmlOffset];
62 if (char === "<") {
63 const tagEnd = highlightedLineHtml.indexOf(">", htmlOffset);
64 if (tagEnd === -1) {
65 result += highlightedLineHtml.slice(htmlOffset);
66 break;
67 }
68 const tag = highlightedLineHtml.slice(htmlOffset, tagEnd + 1);
69 if (markOpen) result += "</mark>";
70 result += tag;
71 if (markOpen) result += openMark();
72 htmlOffset = tagEnd + 1;
73 continue;
74 }
75
76 if (!markOpen && matches[matchIndex]?.start === sourceOffset) {
77 markOpen = true;
78 result += openMark();
79 }
80
81 let token: string;
82 let sourceLength: number;
83 if (char === "&") {
84 const entityEnd = highlightedLineHtml.indexOf(";", htmlOffset);
85 if (entityEnd !== -1) {
86 token = highlightedLineHtml.slice(htmlOffset, entityEnd + 1);
87 sourceLength = decodedEntityLength(token);
88 } else {
89 token = char;
90 sourceLength = 1;
91 }
92 } else {
93 const codePoint = highlightedLineHtml.codePointAt(htmlOffset) ?? 0;
94 sourceLength = codePoint > 0xffff ? 2 : 1;
95 token = highlightedLineHtml.slice(htmlOffset, htmlOffset + sourceLength);
96 }
97
98 result += token;
99 htmlOffset += token.length;
100 sourceOffset += sourceLength;
101
102 if (markOpen && matches[matchIndex]?.end === sourceOffset) {
103 result += "</mark>";
104 markOpen = false;
105 matchIndex += 1;
106 }
107 }
108
109 if (markOpen) result += "</mark>";
110 return result;
111 }
112
113 // A multiline highlight.js span may cross a newline. Each virtual row needs
114 // valid standalone HTML, so close active tags at the boundary and reopen the
115 // same stack on the next line.
116 export function splitHighlightedCodeLines(html: string): string[] {
117 const lines: string[] = [];
118 const openTags: string[] = [];
119 let current = "";
120 let offset = 0;
121
122 while (offset < html.length) {
123 if (html[offset] === "\n") {
124 current += closeTags(openTags);
125 lines.push(current);
126 current = openTags.join("");
127 offset += 1;
128 continue;
129 }
130 if (html[offset] === "<") {
131 const tagEnd = html.indexOf(">", offset);
132 if (tagEnd !== -1) {
133 const tag = html.slice(offset, tagEnd + 1);
134 current += tag;
135 if (/^<(span|mark)\b/.test(tag)) {
136 openTags.push(tag);
137 } else if (/^<\/(span|mark)>$/.test(tag)) {
138 openTags.pop();
139 }
140 offset = tagEnd + 1;
141 continue;
142 }
143 }
144 const codePoint = html.codePointAt(offset) ?? 0;
145 const length = codePoint > 0xffff ? 2 : 1;
146 current += html.slice(offset, offset + length);
147 offset += length;
148 }
149
150 current += closeTags(openTags);
151 lines.push(current);
152 return lines;
153 }
154
155 export default function LineNumberCode({
156 value,
157 language,
158 showLineNumbers,
159 maxHeight,
160 scrollMode,
161 sourceSize,
162 searchRequestPending,
163 onSearchRequestConsumed,
164 }: EditorProps) {
165 const t = useT();
166 const lines = useMemo(() => value.split("\n"), [value]);
167 const syntaxHighlight = shouldHighlightSource(value, sourceSize, lines.length);
168 const baseLineHtmls = useMemo(
169 () => syntaxHighlight
170 ? splitHighlightedCodeLines(highlightToHtml(value, language))
171 : lines.map(escapeHtml),
172 [language, lines, syntaxHighlight, value],
173 );
174
175 const [searchOpen, setSearchOpen] = useState(false);
176 const [query, setQuery] = useState("");
177 const [searchQuery, setSearchQuery] = useState("");
178 const [caseSensitive, setCaseSensitive] = useState(false);
179 const [wholeWord, setWholeWord] = useState(false);
180 const [regexEnabled, setRegexEnabled] = useState(false);
181 const [regexSearchState, setRegexSearchState] = useState<RegexSearchState>({
182 source: value,
183 query: "",
184 caseSensitive: false,
185 wholeWord: false,
186 status: "idle",
187 result: EMPTY_SEARCH_RESULT,
188 });
189 const [currentMatchIdx, setCurrentMatchIdx] = useState(0);
190 const inputRef = useRef<HTMLInputElement>(null);
191 const searchTimerRef = useRef<number | null>(null);
192 const regexRequestIdRef = useRef(0);
193
194 const openSearch = useCallback(() => {
195 setSearchOpen(true);
196 window.setTimeout(() => {
197 inputRef.current?.focus();
198 inputRef.current?.select();
199 }, 0);
200 }, []);
201
202 useEffect(() => {
203 if (!searchRequestPending) return;
204 openSearch();
205 onSearchRequestConsumed?.();
206 }, [onSearchRequestConsumed, openSearch, searchRequestPending]);
207
208 const literalSearchResult = useMemo(
209 () => regexEnabled
210 ? EMPTY_SEARCH_RESULT
211 : findCodeMatches(lines, searchQuery, caseSensitive, wholeWord),
212 [caseSensitive, lines, regexEnabled, searchQuery, wholeWord],
213 );
214 const regexStateIsCurrent =
215 regexSearchState.source === value
216 && regexSearchState.query === searchQuery
217 && regexSearchState.caseSensitive === caseSensitive
218 && regexSearchState.wholeWord === wholeWord;
219 const regexSearchPending = Boolean(
220 regexEnabled
221 && searchQuery
222 && (!regexStateIsCurrent || regexSearchState.status === "pending"),
223 );
224 const regexSearchError = regexEnabled
225 && regexStateIsCurrent
226 && regexSearchState.status === "error"
227 ? regexSearchState.error
228 : undefined;
229 const searchResult = regexEnabled
230 ? regexStateIsCurrent && regexSearchState.status === "ready"
231 ? regexSearchState.result
232 : EMPTY_SEARCH_RESULT
233 : literalSearchResult;
234 const matches = searchResult.matches;
235 const totalMatches = matches.length;
236 const activeMatchIndex = totalMatches > 0 ? currentMatchIdx % totalMatches : 0;
237 const activeMatch = matches[activeMatchIndex];
238 const matchesByLine = useMemo(
239 () => {
240 const grouped = new Map<number, CodeSearchMatch[]>();
241 for (const match of matches) {
242 const lineMatches = grouped.get(match.lineIndex);
243 if (lineMatches) lineMatches.push(match);
244 else grouped.set(match.lineIndex, [match]);
245 }
246 return grouped;
247 },
248 [matches],
249 );
250 const searchPending = query !== searchQuery || regexSearchPending;
251
252 useEffect(() => {
253 regexRequestIdRef.current += 1;
254 const requestId = regexRequestIdRef.current;
255 const baseState = {
256 source: value,
257 query: searchQuery,
258 caseSensitive,
259 wholeWord,
260 result: EMPTY_SEARCH_RESULT,
261 };
262
263 if (!regexEnabled || !searchQuery) {
264 setRegexSearchState({ ...baseState, status: "idle" });
265 return;
266 }
267 if (searchQuery.length > MAX_REGEX_PATTERN_LENGTH) {
268 setRegexSearchState({ ...baseState, status: "error", error: "pattern_too_long" });
269 return;
270 }
271 if (value.length > MAX_REGEX_SOURCE_LENGTH) {
272 setRegexSearchState({ ...baseState, status: "error", error: "source_too_large" });
273 return;
274 }
275
276 setRegexSearchState({ ...baseState, status: "pending" });
277 return startRegexSearch(
278 {
279 requestId,
280 source: value,
281 pattern: searchQuery,
282 caseSensitive,
283 wholeWord,
284 maxMatches: MAX_SEARCH_MATCHES,
285 },
286 {
287 onResponse: (response) => {
288 if (response.requestId !== regexRequestIdRef.current) return;
289 if (response.ok) {
290 setRegexSearchState({ ...baseState, status: "ready", result: response.result });
291 } else {
292 setRegexSearchState({
293 ...baseState,
294 status: "error",
295 error: response.error,
296 detail: response.detail,
297 });
298 }
299 },
300 },
301 );
302 }, [caseSensitive, regexEnabled, searchQuery, value, wholeWord]);
303
304 useEffect(() => {
305 return () => {
306 if (searchTimerRef.current != null) window.clearTimeout(searchTimerRef.current);
307 };
308 }, []);
309
310 const commitSearchQuery = useCallback((nextQuery: string) => {
311 if (searchTimerRef.current != null) {
312 window.clearTimeout(searchTimerRef.current);
313 searchTimerRef.current = null;
314 }
315 setCurrentMatchIdx(0);
316 setSearchQuery(nextQuery);
317 }, []);
318
319 const updateQuery = useCallback((nextQuery: string) => {
320 setQuery(nextQuery);
321 setCurrentMatchIdx(0);
322 if (searchTimerRef.current != null) window.clearTimeout(searchTimerRef.current);
323 searchTimerRef.current = window.setTimeout(() => {
324 searchTimerRef.current = null;
325 setSearchQuery(nextQuery);
326 }, SEARCH_DEBOUNCE_MS);
327 }, []);
328
329 const closeSearch = useCallback(() => {
330 setSearchOpen(false);
331 setQuery("");
332 commitSearchQuery("");
333 }, [commitSearchQuery]);
334
335 const scrollRef = useRef<HTMLDivElement>(null);
336 const isVirtual = showLineNumbers !== false && lines.length > VIRTUAL_THRESHOLD;
337 const bounded = scrollMode === "bounded" || (scrollMode !== "expand" && maxHeight != null) || isVirtual;
338 const virtualizer = useVirtualizer({
339 count: isVirtual ? lines.length : 0,
340 getScrollElement: () => scrollRef.current,
341 estimateSize: () => ROW_HEIGHT_ESTIMATE,
342 overscan: OVERSCAN,
343 // Syntax-highlighted rows can still require measurement when the user
344 // changes typography, but measurement/scroll updates should not feed a
345 // React render loop for a long code block.
346 directDomUpdates: true,
347 });
348
349 // The virtualizer writes positioning straight onto DOM nodes (container
350 // height, scroll offset, per-row transforms). When a large (virtual) file
351 // stays mounted via SWR and is replaced by a small (non-virtual) file, React
352 // reuses those nodes; clear the direct DOM writes so the non-virtual rows
353 // lay out fresh instead of keeping stale offsets and gaps.
354 useEffect(() => {
355 if (isVirtual) return;
356 const scrollEl = scrollRef.current;
357 if (!scrollEl) return;
358 scrollEl.scrollTop = 0;
359 const wrap = scrollEl.querySelector<HTMLElement>(".code-lines-wrap");
360 if (!wrap) return;
361 wrap.style.removeProperty("height");
362 wrap.querySelectorAll<HTMLElement>("[data-line-index]").forEach((row) => {
363 row.style.removeProperty("transform");
364 row.style.removeProperty("position");
365 row.style.removeProperty("top");
366 row.style.removeProperty("left");
367 row.style.removeProperty("width");
368 });
369 }, [isVirtual]);
370
371 const scrollToLine = useCallback(
372 (index: number) => {
373 if (!scrollRef.current) return;
374 if (isVirtual) {
375 virtualizer.scrollToIndex(index, { align: "center" });
376 } else {
377 const row = scrollRef.current.querySelector<HTMLElement>(`[data-line-index="${index}"]`);
378 if (row && typeof row.scrollIntoView === "function") {
379 row.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
380 } else {
381 scrollRef.current.scrollTo({
382 top: index * ROW_HEIGHT_ESTIMATE - scrollRef.current.clientHeight / 2,
383 behavior: "smooth",
384 });
385 }
386 }
387 },
388 [isVirtual, virtualizer],
389 );
390 const scrollToLineRef = useRef(scrollToLine);
391 scrollToLineRef.current = scrollToLine;
392
393 useEffect(() => {
394 setCurrentMatchIdx(0);
395 if (!searchQuery || !matches[0]) return;
396 const timer = window.setTimeout(() => scrollToLineRef.current(matches[0].lineIndex), 0);
397 return () => window.clearTimeout(timer);
398 }, [matches, searchQuery]);
399
400 const jumpToMatch = useCallback(
401 (direction: 1 | -1) => {
402 if (searchPending) {
403 commitSearchQuery(query);
404 return;
405 }
406 if (totalMatches === 0) return;
407 const nextIndex = direction === 1
408 ? (activeMatchIndex + 1) % totalMatches
409 : (activeMatchIndex - 1 + totalMatches) % totalMatches;
410 setCurrentMatchIdx(nextIndex);
411 const lineIndex = matches[nextIndex]?.lineIndex;
412 if (lineIndex != null) scrollToLine(lineIndex);
413 },
414 [activeMatchIndex, commitSearchQuery, matches, query, scrollToLine, searchPending, totalMatches],
415 );
416
417 const lineNoWidth = String(lines.length).length;
418 const renderRow = (index: number) => {
419 const lineNo = index + 1;
420 const lineMatches = matchesByLine.get(index) ?? [];
421 const lineHtml = lineMatches.length > 0
422 ? highlightLineMatches(baseLineHtmls[index] ?? "", lineMatches, activeMatch)
423 : baseLineHtmls[index] ?? "";
424 const hasSettledSearch = searchQuery && !searchPending && !regexSearchError;
425 const isCurrent = hasSettledSearch && activeMatch?.lineIndex === index;
426 const isDimmed = hasSettledSearch && !matchesByLine.has(index);
427 return (
428 <div
429 key={index}
430 data-line-index={index}
431 className={`code-line-row${isCurrent ? " code-line-row--current" : ""}${isDimmed ? " code-line-row--dim" : ""}`}
432 style={{ transform: "none" }}
433 >
434 {showLineNumbers !== false && (
435 <span
436 className="code-line-ln"
437 style={{ minWidth: `${lineNoWidth + 2}ch` }}
438 aria-label={t("workspace.codeLine", { line: lineNo })}
439 >
440 {lineNo}
441 </span>
442 )}
443 <code
444 className="code-line-text"
445 dangerouslySetInnerHTML={{ __html: lineHtml || " " }}
446 />
447 </div>
448 );
449 };
450
451 const totalMatchLabel = searchResult.truncated ? `${totalMatches}+` : totalMatches;
452 const searchErrorLabel = (() => {
453 switch (regexSearchError) {
454 case "invalid_pattern":
455 return t("workspace.searchRegexInvalid");
456 case "pattern_too_long":
457 return t("workspace.searchRegexTooLong");
458 case "source_too_large":
459 return t("workspace.searchRegexSourceTooLarge");
460 case "zero_length_unsupported":
461 return t("workspace.searchRegexZeroLength");
462 case "multiline_unsupported":
463 return t("workspace.searchRegexMultiline");
464 case "timeout":
465 return t("workspace.searchRegexTimeout");
466 case "unavailable":
467 return t("workspace.searchRegexUnavailable");
468 default:
469 return "";
470 }
471 })();
472
473 return (
474 <div
475 className="code-block__wrap"
476 onKeyDownCapture={(event) => {
477 if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f") {
478 event.preventDefault();
479 event.stopPropagation();
480 openSearch();
481 } else if (event.key === "Escape" && searchOpen) {
482 event.preventDefault();
483 event.stopPropagation();
484 closeSearch();
485 window.setTimeout(() => scrollRef.current?.focus(), 0);
486 }
487 }}
488 >
489 {searchOpen && (
490 <div className="code-search">
491 <span className="code-search__icon" aria-hidden="true">
492 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
493 <circle cx="6.5" cy="6.5" r="5" />
494 <path d="M10.5 10.5L14 14" />
495 </svg>
496 </span>
497 <input
498 ref={inputRef}
499 type="text"
500 className="code-search__input"
501 placeholder={t("workspace.searchPlaceholder")}
502 value={query}
503 onChange={(event) => updateQuery(event.target.value)}
504 onKeyDown={(event) => {
505 if (event.key === "Enter") {
506 event.preventDefault();
507 jumpToMatch(event.shiftKey ? -1 : 1);
508 }
509 }}
510 />
511
512 {query && (
513 <span
514 className={`code-search__count${searchErrorLabel ? " code-search__count--error" : ""}`}
515 aria-live="polite"
516 title={searchErrorLabel ? regexSearchState.detail || searchErrorLabel : undefined}
517 >
518 {searchPending
519 ? t("common.loading")
520 : searchErrorLabel
521 ? searchErrorLabel
522 : totalMatches > 0
523 ? t("workspace.searchCount", {
524 current: activeMatchIndex + 1,
525 total: totalMatchLabel,
526 })
527 : t("workspace.searchNoResults")}
528 </span>
529 )}
530
531 <div className="code-search__actions">
532 <button
533 className={`code-search__toggle${caseSensitive ? " code-search__toggle--on" : ""}`}
534 onClick={() => {
535 setCurrentMatchIdx(0);
536 setCaseSensitive((enabled) => !enabled);
537 }}
538 aria-label={t("workspace.searchMatchCase")}
539 aria-pressed={caseSensitive}
540 title={t("workspace.searchMatchCase")}
541 type="button"
542 >
543 Aa
544 </button>
545 <button
546 className={`code-search__toggle${wholeWord ? " code-search__toggle--on" : ""}`}
547 onClick={() => {
548 setCurrentMatchIdx(0);
549 setWholeWord((enabled) => !enabled);
550 }}
551 aria-label={t("workspace.searchWholeWord")}
552 aria-pressed={wholeWord}
553 title={t("workspace.searchWholeWord")}
554 type="button"
555 >
556 ab
557 </button>
558 <button
559 className={`code-search__toggle${regexEnabled ? " code-search__toggle--on" : ""}`}
560 onClick={() => {
561 setCurrentMatchIdx(0);
562 setRegexEnabled((enabled) => !enabled);
563 }}
564 aria-label={t("workspace.searchRegex")}
565 aria-pressed={regexEnabled}
566 title={t("workspace.searchRegex")}
567 type="button"
568 >
569 .*
570 </button>
571
572 {query && !searchPending && totalMatches > 0 && (
573 <>
574 <button
575 className="code-search__nav"
576 onClick={() => jumpToMatch(-1)}
577 aria-label={t("workspace.searchPrevious")}
578 title={t("workspace.searchPrevious")}
579 type="button"
580 >
581 <svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 2L2 6l4 4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
582 </button>
583 <button
584 className="code-search__nav"
585 onClick={() => jumpToMatch(1)}
586 aria-label={t("workspace.searchNext")}
587 title={t("workspace.searchNext")}
588 type="button"
589 >
590 <svg width="12" height="12" viewBox="0 0 12 12"><path d="M2 2l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
591 </button>
592 </>
593 )}
594
595 <CopyButton
596 text={value}
597 className="code-search__copy"
598 showInlineLabel={false}
599 />
600 <button
601 className="code-search__close"
602 onClick={closeSearch}
603 aria-label={t("workspace.searchClose")}
604 title={t("workspace.searchClose")}
605 type="button"
606 >
607
608 </button>
609 </div>
610 </div>
611 )}
612
613 <div
614 ref={scrollRef}
615 className={`code hljs code--lines${bounded ? " code--scroll-y" : ""}`}
616 data-nested-scroll={bounded ? "" : undefined}
617 data-lang={language}
618 data-highlight-mode={syntaxHighlight ? "syntax" : "plain"}
619 tabIndex={0}
620 style={{
621 maxHeight: maxHeight ?? undefined,
622 overflow: bounded ? "auto" : undefined,
623 }}
624 >
625 {isVirtual ? (
626 <div
627 ref={virtualizer.containerRef}
628 className="code-lines-wrap"
629 style={{ width: "100%", position: "relative" }}
630 >
631 {virtualizer.getVirtualItems().map((row) => (
632 <div
633 key={row.key}
634 data-index={row.index}
635 ref={virtualizer.measureElement}
636 style={{
637 position: "absolute",
638 top: 0,
639 left: 0,
640 width: "100%",
641 // directDomUpdates writes the transform straight onto the
642 // DOM, but right after switching a non-virtual view to a
643 // virtual one the virtualizer may emit before its element
644 // cache is populated; rendering the offset here keeps the
645 // rows positioned (no stack-up) until then.
646 transform: `translate3d(0, ${row.start}px, 0)`,
647 }}
648 >
649 {renderRow(row.index)}
650 </div>
651 ))}
652 </div>
653 ) : (
654 <div className="code-lines-wrap" style={{ height: undefined }}>
655 {lines.map((_, index) => renderRow(index))}
656 </div>
657 )}
658 </div>
659 {!searchOpen && <CopyButton text={value} className="code-block__copy" />}
660 </div>
661 );
662 }
663
664 function escapeHtml(value: string): string {
665 return value.replace(/[&<>]/g, (character) => (
666 character === "&" ? "&amp;" : character === "<" ? "&lt;" : "&gt;"
667 ));
668 }
669
670 function decodedEntityLength(entity: string): number {
671 const body = entity.slice(1, -1).toLowerCase();
672 if (["amp", "lt", "gt", "quot", "apos", "#39", "#x27"].includes(body)) return 1;
673 const numeric = body.startsWith("#x")
674 ? Number.parseInt(body.slice(2), 16)
675 : body.startsWith("#")
676 ? Number.parseInt(body.slice(1), 10)
677 : Number.NaN;
678 return Number.isFinite(numeric) && numeric >= 0 && numeric <= 0x10ffff
679 ? String.fromCodePoint(numeric).length
680 : entity.length;
681 }
682
683 function closeTags(openTags: string[]): string {
684 return [...openTags]
685 .reverse()
686 .map((tag) => tag.startsWith("<mark") ? "</mark>" : "</span>")
687 .join("");
688 }
689
689 lines Plain Text