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