返回 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 sourceSize,
161 searchRequestPending,
162 onSearchRequestConsumed,
163 }: EditorProps) {
164 const t = useT();
165 const lines = useMemo(() => value.split("\n"), [value]);
166 const syntaxHighlight = shouldHighlightSource(value, sourceSize, lines.length);
167 const baseLineHtmls = useMemo(
168 () => syntaxHighlight
169 ? splitHighlightedCodeLines(highlightToHtml(value, language))
170 : lines.map(escapeHtml),
171 [language, lines, syntaxHighlight, value],
172 );
173
174 const [searchOpen, setSearchOpen] = useState(false);
175 const [query, setQuery] = useState("");
176 const [searchQuery, setSearchQuery] = useState("");
177 const [caseSensitive, setCaseSensitive] = useState(false);
178 const [wholeWord, setWholeWord] = useState(false);
179 const [regexEnabled, setRegexEnabled] = useState(false);
180 const [regexSearchState, setRegexSearchState] = useState<RegexSearchState>({
181 source: value,
182 query: "",
183 caseSensitive: false,
184 wholeWord: false,
185 status: "idle",
186 result: EMPTY_SEARCH_RESULT,
187 });
188 const [currentMatchIdx, setCurrentMatchIdx] = useState(0);
189 const inputRef = useRef<HTMLInputElement>(null);
190 const searchTimerRef = useRef<number | null>(null);
191 const regexRequestIdRef = useRef(0);
192
193 const openSearch = useCallback(() => {
194 setSearchOpen(true);
195 window.setTimeout(() => {
196 inputRef.current?.focus();
197 inputRef.current?.select();
198 }, 0);
199 }, []);
200
201 useEffect(() => {
202 if (!searchRequestPending) return;
203 openSearch();
204 onSearchRequestConsumed?.();
205 }, [onSearchRequestConsumed, openSearch, searchRequestPending]);
206
207 const literalSearchResult = useMemo(
208 () => regexEnabled
209 ? EMPTY_SEARCH_RESULT
210 : findCodeMatches(lines, searchQuery, caseSensitive, wholeWord),
211 [caseSensitive, lines, regexEnabled, searchQuery, wholeWord],
212 );
213 const regexStateIsCurrent =
214 regexSearchState.source === value
215 && regexSearchState.query === searchQuery
216 && regexSearchState.caseSensitive === caseSensitive
217 && regexSearchState.wholeWord === wholeWord;
218 const regexSearchPending = Boolean(
219 regexEnabled
220 && searchQuery
221 && (!regexStateIsCurrent || regexSearchState.status === "pending"),
222 );
223 const regexSearchError = regexEnabled
224 && regexStateIsCurrent
225 && regexSearchState.status === "error"
226 ? regexSearchState.error
227 : undefined;
228 const searchResult = regexEnabled
229 ? regexStateIsCurrent && regexSearchState.status === "ready"
230 ? regexSearchState.result
231 : EMPTY_SEARCH_RESULT
232 : literalSearchResult;
233 const matches = searchResult.matches;
234 const totalMatches = matches.length;
235 const activeMatchIndex = totalMatches > 0 ? currentMatchIdx % totalMatches : 0;
236 const activeMatch = matches[activeMatchIndex];
237 const matchesByLine = useMemo(
238 () => {
239 const grouped = new Map<number, CodeSearchMatch[]>();
240 for (const match of matches) {
241 const lineMatches = grouped.get(match.lineIndex);
242 if (lineMatches) lineMatches.push(match);
243 else grouped.set(match.lineIndex, [match]);
244 }
245 return grouped;
246 },
247 [matches],
248 );
249 const searchPending = query !== searchQuery || regexSearchPending;
250
251 useEffect(() => {
252 regexRequestIdRef.current += 1;
253 const requestId = regexRequestIdRef.current;
254 const baseState = {
255 source: value,
256 query: searchQuery,
257 caseSensitive,
258 wholeWord,
259 result: EMPTY_SEARCH_RESULT,
260 };
261
262 if (!regexEnabled || !searchQuery) {
263 setRegexSearchState({ ...baseState, status: "idle" });
264 return;
265 }
266 if (searchQuery.length > MAX_REGEX_PATTERN_LENGTH) {
267 setRegexSearchState({ ...baseState, status: "error", error: "pattern_too_long" });
268 return;
269 }
270 if (value.length > MAX_REGEX_SOURCE_LENGTH) {
271 setRegexSearchState({ ...baseState, status: "error", error: "source_too_large" });
272 return;
273 }
274
275 setRegexSearchState({ ...baseState, status: "pending" });
276 return startRegexSearch(
277 {
278 requestId,
279 source: value,
280 pattern: searchQuery,
281 caseSensitive,
282 wholeWord,
283 maxMatches: MAX_SEARCH_MATCHES,
284 },
285 {
286 onResponse: (response) => {
287 if (response.requestId !== regexRequestIdRef.current) return;
288 if (response.ok) {
289 setRegexSearchState({ ...baseState, status: "ready", result: response.result });
290 } else {
291 setRegexSearchState({
292 ...baseState,
293 status: "error",
294 error: response.error,
295 detail: response.detail,
296 });
297 }
298 },
299 },
300 );
301 }, [caseSensitive, regexEnabled, searchQuery, value, wholeWord]);
302
303 useEffect(() => {
304 return () => {
305 if (searchTimerRef.current != null) window.clearTimeout(searchTimerRef.current);
306 };
307 }, []);
308
309 const commitSearchQuery = useCallback((nextQuery: string) => {
310 if (searchTimerRef.current != null) {
311 window.clearTimeout(searchTimerRef.current);
312 searchTimerRef.current = null;
313 }
314 setCurrentMatchIdx(0);
315 setSearchQuery(nextQuery);
316 }, []);
317
318 const updateQuery = useCallback((nextQuery: string) => {
319 setQuery(nextQuery);
320 setCurrentMatchIdx(0);
321 if (searchTimerRef.current != null) window.clearTimeout(searchTimerRef.current);
322 searchTimerRef.current = window.setTimeout(() => {
323 searchTimerRef.current = null;
324 setSearchQuery(nextQuery);
325 }, SEARCH_DEBOUNCE_MS);
326 }, []);
327
328 const closeSearch = useCallback(() => {
329 setSearchOpen(false);
330 setQuery("");
331 commitSearchQuery("");
332 }, [commitSearchQuery]);
333
334 const scrollRef = useRef<HTMLDivElement>(null);
335 const isVirtual = showLineNumbers !== false && lines.length > VIRTUAL_THRESHOLD;
336 const virtualizer = useVirtualizer({
337 count: isVirtual ? lines.length : 0,
338 getScrollElement: () => scrollRef.current,
339 estimateSize: () => ROW_HEIGHT_ESTIMATE,
340 overscan: OVERSCAN,
341 // Syntax-highlighted rows can still require measurement when the user
342 // changes typography, but measurement/scroll updates should not feed a
343 // React render loop for a long code block.
344 directDomUpdates: true,
345 });
346
347 const scrollToLine = useCallback(
348 (index: number) => {
349 if (!scrollRef.current) return;
350 if (isVirtual) {
351 virtualizer.scrollToIndex(index, { align: "center" });
352 } else {
353 const row = scrollRef.current.querySelector<HTMLElement>(`[data-line-index="${index}"]`);
354 if (row && typeof row.scrollIntoView === "function") {
355 row.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
356 } else {
357 scrollRef.current.scrollTo({
358 top: index * ROW_HEIGHT_ESTIMATE - scrollRef.current.clientHeight / 2,
359 behavior: "smooth",
360 });
361 }
362 }
363 },
364 [isVirtual, virtualizer],
365 );
366 const scrollToLineRef = useRef(scrollToLine);
367 scrollToLineRef.current = scrollToLine;
368
369 useEffect(() => {
370 setCurrentMatchIdx(0);
371 if (!searchQuery || !matches[0]) return;
372 const timer = window.setTimeout(() => scrollToLineRef.current(matches[0].lineIndex), 0);
373 return () => window.clearTimeout(timer);
374 }, [matches, searchQuery]);
375
376 const jumpToMatch = useCallback(
377 (direction: 1 | -1) => {
378 if (searchPending) {
379 commitSearchQuery(query);
380 return;
381 }
382 if (totalMatches === 0) return;
383 const nextIndex = direction === 1
384 ? (activeMatchIndex + 1) % totalMatches
385 : (activeMatchIndex - 1 + totalMatches) % totalMatches;
386 setCurrentMatchIdx(nextIndex);
387 const lineIndex = matches[nextIndex]?.lineIndex;
388 if (lineIndex != null) scrollToLine(lineIndex);
389 },
390 [activeMatchIndex, commitSearchQuery, matches, query, scrollToLine, searchPending, totalMatches],
391 );
392
393 const lineNoWidth = String(lines.length).length;
394 const renderRow = (index: number) => {
395 const lineNo = index + 1;
396 const lineMatches = matchesByLine.get(index) ?? [];
397 const lineHtml = lineMatches.length > 0
398 ? highlightLineMatches(baseLineHtmls[index] ?? "", lineMatches, activeMatch)
399 : baseLineHtmls[index] ?? "";
400 const hasSettledSearch = searchQuery && !searchPending && !regexSearchError;
401 const isCurrent = hasSettledSearch && activeMatch?.lineIndex === index;
402 const isDimmed = hasSettledSearch && !matchesByLine.has(index);
403 return (
404 <div
405 key={index}
406 data-line-index={index}
407 className={`code-line-row${isCurrent ? " code-line-row--current" : ""}${isDimmed ? " code-line-row--dim" : ""}`}
408 >
409 {showLineNumbers !== false && (
410 <span
411 className="code-line-ln"
412 style={{ minWidth: `${lineNoWidth + 2}ch` }}
413 aria-label={t("workspace.codeLine", { line: lineNo })}
414 >
415 {lineNo}
416 </span>
417 )}
418 <code
419 className="code-line-text"
420 dangerouslySetInnerHTML={{ __html: lineHtml || " " }}
421 />
422 </div>
423 );
424 };
425
426 const totalMatchLabel = searchResult.truncated ? `${totalMatches}+` : totalMatches;
427 const searchErrorLabel = (() => {
428 switch (regexSearchError) {
429 case "invalid_pattern":
430 return t("workspace.searchRegexInvalid");
431 case "pattern_too_long":
432 return t("workspace.searchRegexTooLong");
433 case "source_too_large":
434 return t("workspace.searchRegexSourceTooLarge");
435 case "zero_length_unsupported":
436 return t("workspace.searchRegexZeroLength");
437 case "multiline_unsupported":
438 return t("workspace.searchRegexMultiline");
439 case "timeout":
440 return t("workspace.searchRegexTimeout");
441 case "unavailable":
442 return t("workspace.searchRegexUnavailable");
443 default:
444 return "";
445 }
446 })();
447
448 return (
449 <div
450 className="code-block__wrap"
451 onKeyDownCapture={(event) => {
452 if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f") {
453 event.preventDefault();
454 event.stopPropagation();
455 openSearch();
456 } else if (event.key === "Escape" && searchOpen) {
457 event.preventDefault();
458 event.stopPropagation();
459 closeSearch();
460 window.setTimeout(() => scrollRef.current?.focus(), 0);
461 }
462 }}
463 >
464 {searchOpen && (
465 <div className="code-search">
466 <span className="code-search__icon" aria-hidden="true">
467 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
468 <circle cx="6.5" cy="6.5" r="5" />
469 <path d="M10.5 10.5L14 14" />
470 </svg>
471 </span>
472 <input
473 ref={inputRef}
474 type="text"
475 className="code-search__input"
476 placeholder={t("workspace.searchPlaceholder")}
477 value={query}
478 onChange={(event) => updateQuery(event.target.value)}
479 onKeyDown={(event) => {
480 if (event.key === "Enter") {
481 event.preventDefault();
482 jumpToMatch(event.shiftKey ? -1 : 1);
483 }
484 }}
485 />
486
487 {query && (
488 <span
489 className={`code-search__count${searchErrorLabel ? " code-search__count--error" : ""}`}
490 aria-live="polite"
491 title={searchErrorLabel ? regexSearchState.detail || searchErrorLabel : undefined}
492 >
493 {searchPending
494 ? t("common.loading")
495 : searchErrorLabel
496 ? searchErrorLabel
497 : totalMatches > 0
498 ? t("workspace.searchCount", {
499 current: activeMatchIndex + 1,
500 total: totalMatchLabel,
501 })
502 : t("workspace.searchNoResults")}
503 </span>
504 )}
505
506 <div className="code-search__actions">
507 <button
508 className={`code-search__toggle${caseSensitive ? " code-search__toggle--on" : ""}`}
509 onClick={() => {
510 setCurrentMatchIdx(0);
511 setCaseSensitive((enabled) => !enabled);
512 }}
513 aria-label={t("workspace.searchMatchCase")}
514 aria-pressed={caseSensitive}
515 title={t("workspace.searchMatchCase")}
516 type="button"
517 >
518 Aa
519 </button>
520 <button
521 className={`code-search__toggle${wholeWord ? " code-search__toggle--on" : ""}`}
522 onClick={() => {
523 setCurrentMatchIdx(0);
524 setWholeWord((enabled) => !enabled);
525 }}
526 aria-label={t("workspace.searchWholeWord")}
527 aria-pressed={wholeWord}
528 title={t("workspace.searchWholeWord")}
529 type="button"
530 >
531 ab
532 </button>
533 <button
534 className={`code-search__toggle${regexEnabled ? " code-search__toggle--on" : ""}`}
535 onClick={() => {
536 setCurrentMatchIdx(0);
537 setRegexEnabled((enabled) => !enabled);
538 }}
539 aria-label={t("workspace.searchRegex")}
540 aria-pressed={regexEnabled}
541 title={t("workspace.searchRegex")}
542 type="button"
543 >
544 .*
545 </button>
546
547 {query && !searchPending && totalMatches > 0 && (
548 <>
549 <button
550 className="code-search__nav"
551 onClick={() => jumpToMatch(-1)}
552 aria-label={t("workspace.searchPrevious")}
553 title={t("workspace.searchPrevious")}
554 type="button"
555 >
556 <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>
557 </button>
558 <button
559 className="code-search__nav"
560 onClick={() => jumpToMatch(1)}
561 aria-label={t("workspace.searchNext")}
562 title={t("workspace.searchNext")}
563 type="button"
564 >
565 <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>
566 </button>
567 </>
568 )}
569
570 <CopyButton
571 text={value}
572 className="code-search__copy"
573 showInlineLabel={false}
574 />
575 <button
576 className="code-search__close"
577 onClick={closeSearch}
578 aria-label={t("workspace.searchClose")}
579 title={t("workspace.searchClose")}
580 type="button"
581 >
582
583 </button>
584 </div>
585 </div>
586 )}
587
588 <div
589 ref={scrollRef}
590 className="code hljs code--lines"
591 data-lang={language}
592 data-highlight-mode={syntaxHighlight ? "syntax" : "plain"}
593 tabIndex={0}
594 style={{
595 maxHeight: maxHeight ?? undefined,
596 overflow: maxHeight != null || isVirtual ? "auto" : undefined,
597 }}
598 >
599 {isVirtual ? (
600 <div
601 ref={virtualizer.containerRef}
602 className="code-lines-wrap"
603 style={{ width: "100%", position: "relative" }}
604 >
605 {virtualizer.getVirtualItems().map((row) => (
606 <div
607 key={row.key}
608 data-index={row.index}
609 ref={virtualizer.measureElement}
610 style={{
611 position: "absolute",
612 top: 0,
613 left: 0,
614 width: "100%",
615 }}
616 >
617 {renderRow(row.index)}
618 </div>
619 ))}
620 </div>
621 ) : (
622 <div className="code-lines-wrap">
623 {lines.map((_, index) => renderRow(index))}
624 </div>
625 )}
626 </div>
627 {!searchOpen && <CopyButton text={value} className="code-block__copy" />}
628 </div>
629 );
630 }
631
632 function escapeHtml(value: string): string {
633 return value.replace(/[&<>]/g, (character) => (
634 character === "&" ? "&amp;" : character === "<" ? "&lt;" : "&gt;"
635 ));
636 }
637
638 function decodedEntityLength(entity: string): number {
639 const body = entity.slice(1, -1).toLowerCase();
640 if (["amp", "lt", "gt", "quot", "apos", "#39", "#x27"].includes(body)) return 1;
641 const numeric = body.startsWith("#x")
642 ? Number.parseInt(body.slice(2), 16)
643 : body.startsWith("#")
644 ? Number.parseInt(body.slice(1), 10)
645 : Number.NaN;
646 return Number.isFinite(numeric) && numeric >= 0 && numeric <= 0x10ffff
647 ? String.fromCodePoint(numeric).length
648 : entity.length;
649 }
650
651 function closeTags(openTags: string[]): string {
652 return [...openTags]
653 .reverse()
654 .map((tag) => tag.startsWith("<mark") ? "</mark>" : "</span>")
655 .join("");
656 }
657
657 lines Plain Text