Skip to content

Commit 9f59a09

Browse files
eunjin11claude
andauthored
add network search (#47)
* feat: 네트워크 탭에 검색 기능 추가 URL, request body, response body에서 검색하고 일치하는 부분을 하이라이팅한다. 일치한 요청은 자동으로 펼쳐지며 일치한 탭(response 우선)을 보여주고, 목록은 첫 번째 일치 항목으로 스크롤 포커스한다. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: 네트워크 검색 결과 위/아래 매치 이동 기능 추가 검색어가 여러 번 등장하면 검색바의 ▲/▼ 버튼으로 매치 사이를 순환 이동한다. 현재 매치 위치를 "n/총개수"로 표시하고, 이동 시 해당 요청으로 스크롤하며 일치한 탭으로 전환한다. 현재 선택된 매치는 진한 색으로, 나머지 매치는 옅은 색으로 구분해 하이라이팅한다. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: 네트워크 검색 로직을 훅과 컴포넌트로 분리 - 검색 상태/매치 네비게이션을 useNetworkSearch 훅으로 추출 - 검색바 UI를 NetworkSearchBar 컴포넌트로 분리(▲/▼ 버튼 중복 제거) - 탭 렌더링을 TABS 상수로 단순화, General 탭 URL 행 중복 제거 - NetworkPanel.tsx 611 → 389줄 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: 검색 매치로 스크롤 시 항목을 화면 중앙에 정렬 - scrollToPosition에 alignTo "middle" 적용. top 정렬은 상단 헤더에 가리거나 목록 끝 근처 매치를 못 올려 안 보이던 문제를 해결 - 검색창에서 엔터로 다음 매치 이동(bindconfirm) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: 검색 매치가 있는 body를 상단에 노출되도록 스크롤 포커스 개선 - 스크롤 정렬을 middle → top으로 변경해 매치 항목 상단을 검색바 바로 아래로 - NetworkDetailSection에서 활성 매치가 있는 body를 Headers보다 위에 렌더해 긴 헤더가 body를 밀어내 focus가 헤더에 잡히던 문제 해결 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: 네트워크 헤더 검색 + 헤더 토글(기본 접힘) 추가 - Headers를 토글로 변경. 기본 접힘이고, 탭하거나 검색 결과가 헤더에 포함되면 펼쳐진다. body를 header 위로 옮겼던 변경은 되돌림 - request/response 헤더(key/value)도 검색·하이라이팅 대상에 포함 - getActiveOccurrence를 필드 단위로 정교화 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: 검색 하이라이트를 파스텔 톤(버터·살구)으로 변경 테마에 highlight 색상(라이트/다크)을 추가하고 HighlightText가 참조하도록 함. 옅은 버터=전체 매치, 살구=현재 선택 매치. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: 헤더 매치도 현재 선택(진한 색) 구분 적용 SearchMatch를 field 단위 → 렌더 노드(nodeKey) 단위로 일반화. matchNode 키 생성기를 훅·렌더가 공유해 카운트 순서와 렌더 순서를 일치시킴. 이로써 url·body뿐 아니라 헤더 key/value 셀에도 활성 매치 강조가 적용된다. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: 매치 노드를 scrollIntoView로 정확히 상단 정렬 헤더 중간 행 매치 시 항목 단위 스크롤로는 화면 아래로 내려가던 문제 해결. 활성 매치 노드(헤더 행/body 섹션/url 행)에만 ref를 달고 scrollIntoView로 상단(block:start) 정렬한다. 아직 렌더 안 된 먼 항목은 scrollToPosition으로 먼저 끌어오는 fallback 유지. 활성 항목 탭은 렌더 시점에 동기 결정하도록 변경. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: 매치 이동 시 scrollToPosition을 항상 실행해 스크롤 복구 직전 변경에서 렌더된 항목은 scrollIntoView만 타도록 했는데, list 안에서 scrollIntoView가 동작하지 않아 엔터/위아래 이동 시 스크롤이 멈췄다. scrollToPosition(항목 단위)을 항상 실행하고, scrollIntoView는 지원 시 정밀 보정으로만 얹는다. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: boundingClientRect로 매치 노드를 리스트 상단에 정확히 정렬 scrollToPosition 후 activeNodeRef.boundingClientRect와 listRef.boundingClientRect의 top 차이를 scrollBy로 보정해 리스트 아이템 내부의 헤더 행도 화면 상단에 정확히 스크롤되도록 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: 코드 리뷰 지적 사항 2건 수정 - useNetworkSearch: activeNodeRef를 success 콜백 내부가 아닌 effect 시작 시점에 캡처해 빠른 연속 이동 시 race condition 방지 - NetworkDetailSection: highlightQuery 변경 시 manualOpen을 null로 초기화해 새 검색어의 헤더 매치가 자동 펼침되도록 수정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: invoke 콜백 지옥을 invokeAsync 헬퍼로 평탄화 Lynx invoke 콜백을 Promise로 래핑하는 invokeAsync 헬퍼를 추가하고, 3단 중첩 success 콜백을 async/await + Promise.all로 교체. boundingClientRect 두 건은 병렬 조회로 변경. listRef.current도 effect 시작 시 한 번만 읽도록 스냅샷으로 캡처. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: 검색 매치 탐색을 matchIndices로 단일화 splitHighlight(강조)와 countOccurrences(카운트)가 각각 구현하던 indexOf 루프를 matchIndices 하나로 통합. 두 함수가 같은 매치 정의를 공유해 한쪽만 바뀌어 어긋나는 일을 방지. 카운트는 number[] 한 번만 할당해 무거운 세그먼트 생성을 피한다. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: 헤더 토글 리셋을 effect 대신 렌더 단계로 이동 검색어 변경 시 manualOpen 초기화를 useEffect에서 렌더 단계 직접 리셋 (prevQuery 추적)으로 교체. 추가 페인트·깜빡임 없는 React 권장 패턴. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: 스크롤 effect에 cleanup을 추가해 빠른 이동 시 레이스 차단 매치를 빠르게 연속 이동하면 늦게 시작한 체인이 먼저 끝나며 옛 스크롤이 이길 수 있었다. cancelled 플래그와 cleanup 반환으로 이전 체인을 중단한다. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: NetworkPanel을 항목·탭 컴포넌트로 분리, 매치 항목 접기 가능 407줄짜리 NetworkPanel을 분리: - NetworkListItem: 항목 1개 렌더(헤더·path·탭·콘텐츠) - NetworkGeneralSection: General 탭(Request/Response와 대칭) - utils/networkFormat: 색상·포맷 순수 함수 함께 매치 버그 수정: 매치로 자동 펼쳐진 항목이 탭해도 접히지 않던 문제를 collapsedIds 집합으로 해결(검색 외 단일 아코디언 동작은 유지). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: 스크롤 로직을 useScrollToActiveMatch 훅으로 분리 + 깜빡임 개선 매치 이동 스크롤을 useNetworkSearch에서 떼어내 전용 훅으로 분리하고, Lynx invoke→Promise 래퍼(invokeAsync)도 공용 유틸로 추출. 함께 깜빡임 개선: 같은 항목·같은 탭 안에서 이동할 땐 레이아웃이 안정적이라 단일 scrollBy로 부드럽게 정렬한다. 탭/항목이 바뀌면 헤더 펼침 등으로 레이아웃이 새로 잡혀 boundingClientRect 측정이 빗나가므로, 기존처럼 scrollToPosition으로 레이아웃을 확정한 뒤 보정하는 2단계를 유지한다. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 53cd393 commit 9f59a09

13 files changed

Lines changed: 1190 additions & 385 deletions

.changeset/network-search.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lynx-console": minor
3+
---
4+
5+
Add search to the Network panel. The search box matches against the request URL, request/response headers, and request/response bodies. Matching requests auto-expand to the tab that contains the hit, the matched text is highlighted, and the list scrolls to the match. Use the ▲/▼ buttons (or Enter) to jump between matches. Request/response headers are collapsed by default and expand on tap or when a match is inside them.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { useThemeColors } from "../styles/ThemeContext";
2+
import { fontWeight } from "../styles/theme";
3+
4+
interface HighlightSegment {
5+
text: string;
6+
match: boolean;
7+
// 일치 세그먼트일 때 해당 텍스트 안에서의 0-based 등장 순번
8+
occurrence?: number;
9+
}
10+
11+
// query가 등장하는 시작 인덱스들을 대소문자 구분 없이 반환한다.
12+
// 강조(splitHighlight)와 카운트(countOccurrences)가 같은 매치 정의를
13+
// 공유하도록 하는 단일 출처. 한쪽만 바뀌어 어긋나는 일을 막는다.
14+
function matchIndices(text: string, query: string): number[] {
15+
const q = query.trim().toLowerCase();
16+
if (!q) return [];
17+
const lower = text.toLowerCase();
18+
const indices: number[] = [];
19+
let from = 0;
20+
while (true) {
21+
const idx = lower.indexOf(q, from);
22+
if (idx === -1) break;
23+
indices.push(idx);
24+
from = idx + q.length;
25+
}
26+
return indices;
27+
}
28+
29+
// query에 일치하는 부분을 대소문자 구분 없이 잘라 세그먼트로 반환
30+
export function splitHighlight(
31+
text: string,
32+
query: string,
33+
): HighlightSegment[] {
34+
const q = query.trim().toLowerCase();
35+
const indices = matchIndices(text, query);
36+
if (indices.length === 0) return [{ text, match: false }];
37+
38+
const segments: HighlightSegment[] = [];
39+
let cursor = 0;
40+
indices.forEach((idx, occurrence) => {
41+
if (idx > cursor) {
42+
segments.push({ text: text.slice(cursor, idx), match: false });
43+
}
44+
segments.push({
45+
text: text.slice(idx, idx + q.length),
46+
match: true,
47+
occurrence,
48+
});
49+
cursor = idx + q.length;
50+
});
51+
if (cursor < text.length) {
52+
segments.push({ text: text.slice(cursor), match: false });
53+
}
54+
55+
return segments;
56+
}
57+
58+
export function textIncludes(text: string | undefined, query: string): boolean {
59+
if (!text) return false;
60+
const q = query.trim().toLowerCase();
61+
if (!q) return false;
62+
return text.toLowerCase().includes(q);
63+
}
64+
65+
// text 안에 query가 몇 번 등장하는지 센다(대소문자 무시)
66+
export function countOccurrences(
67+
text: string | undefined,
68+
query: string,
69+
): number {
70+
if (!text) return 0;
71+
return matchIndices(text, query).length;
72+
}
73+
74+
interface HighlightTextProps {
75+
text: string;
76+
query: string;
77+
// 이 텍스트 안에서 "현재 선택된" 매치의 등장 순번(없으면 -1)
78+
activeOccurrence?: number | undefined;
79+
className?: string | undefined;
80+
style?: Record<string, unknown> | undefined;
81+
}
82+
83+
/**
84+
* query에 일치하는 부분을 강조 표시하는 텍스트.
85+
* 일치하지 않는 부분은 부모 <text>의 스타일을 그대로 상속하고,
86+
* 일치하는 부분만 중첩 <text>로 감싸 하이라이트 배경을 입힌다.
87+
* activeOccurrence와 순번이 같은 매치는 "현재 선택" 스타일로 강조한다.
88+
*/
89+
export const HighlightText = ({
90+
text,
91+
query,
92+
activeOccurrence = -1,
93+
className,
94+
style,
95+
}: HighlightTextProps) => {
96+
const colors = useThemeColors();
97+
const segments = splitHighlight(text, query);
98+
99+
if (segments.length === 1 && !segments[0]?.match) {
100+
return (
101+
<text className={className} style={style}>
102+
{text}
103+
</text>
104+
);
105+
}
106+
107+
return (
108+
<text className={className} style={style}>
109+
{segments.map((segment, index) => {
110+
if (!segment.match) return segment.text;
111+
const isActive = segment.occurrence === activeOccurrence;
112+
return (
113+
<text
114+
// biome-ignore lint: 세그먼트는 텍스트 순서로 안정적
115+
key={`hl-${index}`}
116+
style={{
117+
backgroundColor: isActive
118+
? colors.highlight.activeBg
119+
: colors.highlight.matchBg,
120+
color: isActive
121+
? colors.highlight.activeText
122+
: colors.highlight.matchText,
123+
fontWeight: fontWeight.bold,
124+
}}
125+
>
126+
{segment.text}
127+
</text>
128+
);
129+
})}
130+
</text>
131+
);
132+
};

package/src/components/NetworkDetailSection.tsx

Lines changed: 108 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,136 @@
1+
import { type RefObject, useState } from "@lynx-js/react";
2+
import type { NodesRef } from "@lynx-js/types";
3+
import { matchNode } from "../hooks/useNetworkSearch";
14
import { useThemeColors } from "../styles/ThemeContext";
25
import { fontWeight } from "../styles/theme";
6+
import { HighlightText, textIncludes } from "./HighlightText";
37
import "./NetworkPanel.css";
48

59
interface NetworkDetailSectionProps {
10+
section: "request" | "response";
611
headers?: Record<string, string> | undefined;
712
body?: string | undefined;
813
error?: string | undefined;
14+
highlightQuery?: string | undefined;
15+
// 이 항목 안에서 nodeKey에 해당하는 노드의 활성 매치 등장 순번(없으면 -1)
16+
getActiveOccurrence?: ((nodeKey: string) => number) | undefined;
17+
// 활성 매치 노드면 스크롤용 ref를 돌려준다(아니면 undefined)
18+
getNodeRef?:
19+
| ((nodeKey: string) => RefObject<NodesRef> | undefined)
20+
| undefined;
921
}
1022

1123
export const NetworkDetailSection = ({
24+
section,
1225
headers = {},
1326
body = "",
1427
error = "",
28+
highlightQuery = "",
29+
getActiveOccurrence = () => -1,
30+
getNodeRef = () => undefined,
1531
}: NetworkDetailSectionProps) => {
1632
const colors = useThemeColors();
1733

34+
// 헤더 행 안의 key 또는 value가 활성 매치면 그 행에 스크롤 ref를 단다
35+
const rowRef = (name: string) =>
36+
getNodeRef(matchNode.headerKey(section, name)) ??
37+
getNodeRef(matchNode.headerValue(section, name));
38+
39+
const headerEntries = Object.entries(headers);
40+
const headerHasMatch = headerEntries.some(
41+
([key, value]) =>
42+
textIncludes(key, highlightQuery) || textIncludes(value, highlightQuery),
43+
);
44+
// 헤더는 기본 접힘. 검색 결과가 헤더에 있으면 자동으로 펼치고, 탭하면 수동 토글
45+
const [manualOpen, setManualOpen] = useState<boolean | null>(null);
46+
// 검색어가 바뀌면 수동 상태를 초기화해 새 매치 여부를 자동으로 반영한다.
47+
// effect 대신 렌더 단계에서 직접 리셋(추가 페인트·깜빡임 없음 — React 권장 패턴)
48+
const [prevQuery, setPrevQuery] = useState(highlightQuery);
49+
if (prevQuery !== highlightQuery) {
50+
setPrevQuery(highlightQuery);
51+
setManualOpen(null);
52+
}
53+
const headersOpen = manualOpen ?? headerHasMatch;
54+
1855
return (
1956
<>
20-
{/* Headers */}
57+
{/* Headers (토글) */}
2158
<view className={"np-detailSection"}>
22-
<text
23-
className={"np-detailSectionTitle t3"}
24-
style={{ fontWeight: fontWeight.bold, color: colors.fg.neutral }}
59+
<view
60+
className={"np-detailSectionHeader"}
61+
bindtap={() => setManualOpen(!headersOpen)}
2562
>
26-
Headers
27-
</text>
28-
{headers && Object.keys(headers).length > 0 ? (
29-
<view className={"np-table"}>
30-
{Object.entries(headers).map(([key, value]) => (
31-
<view
32-
key={key}
33-
className={"np-tableRow"}
34-
style={{ backgroundColor: colors.bg.neutralWeak }}
35-
>
36-
<text
37-
className={"np-tableKey t3"}
38-
style={{
39-
fontWeight: fontWeight.bold,
40-
color: colors.fg.neutralSubtle,
41-
}}
42-
>
43-
{key}
44-
</text>
45-
<text
46-
className={"np-tableValue t3"}
47-
style={{
48-
fontWeight: fontWeight.regular,
49-
color: colors.fg.neutral,
50-
}}
51-
>
52-
{value}
53-
</text>
54-
</view>
55-
))}
56-
</view>
57-
) : (
5863
<text
59-
className={"np-emptyText t3"}
64+
className={"t2"}
6065
style={{
6166
fontWeight: fontWeight.regular,
62-
color: colors.fg.disabled,
67+
color: colors.fg.neutralSubtle,
6368
}}
6469
>
65-
No headers
70+
{headersOpen ? "▼" : "▶"}
6671
</text>
67-
)}
72+
<text
73+
className={"t3"}
74+
style={{ fontWeight: fontWeight.bold, color: colors.fg.neutral }}
75+
>
76+
Headers
77+
</text>
78+
</view>
79+
{headersOpen &&
80+
(headerEntries.length > 0 ? (
81+
<view className={"np-table"}>
82+
{headerEntries.map(([key, value]) => (
83+
<view
84+
key={key}
85+
ref={rowRef(key)}
86+
className={"np-tableRow"}
87+
style={{ backgroundColor: colors.bg.neutralWeak }}
88+
>
89+
<HighlightText
90+
text={key}
91+
query={highlightQuery}
92+
activeOccurrence={getActiveOccurrence(
93+
matchNode.headerKey(section, key),
94+
)}
95+
className={"np-tableKey t3"}
96+
style={{
97+
fontWeight: fontWeight.bold,
98+
color: colors.fg.neutralSubtle,
99+
}}
100+
/>
101+
<HighlightText
102+
text={value}
103+
query={highlightQuery}
104+
activeOccurrence={getActiveOccurrence(
105+
matchNode.headerValue(section, key),
106+
)}
107+
className={"np-tableValue t3"}
108+
style={{
109+
fontWeight: fontWeight.regular,
110+
color: colors.fg.neutral,
111+
}}
112+
/>
113+
</view>
114+
))}
115+
</view>
116+
) : (
117+
<text
118+
className={"np-emptyText t3"}
119+
style={{
120+
fontWeight: fontWeight.regular,
121+
color: colors.fg.disabled,
122+
}}
123+
>
124+
No headers
125+
</text>
126+
))}
68127
</view>
69128

70129
{/* Body */}
71-
<view className={"np-detailSection"}>
130+
<view
131+
className={"np-detailSection"}
132+
ref={getNodeRef(matchNode.body(section))}
133+
>
72134
<text
73135
className={"np-detailSectionTitle t3"}
74136
style={{ fontWeight: fontWeight.bold, color: colors.fg.neutral }}
@@ -88,16 +150,17 @@ export const NetworkDetailSection = ({
88150
</text>
89151
)}
90152
{body && (
91-
<text
153+
<HighlightText
154+
text={body}
155+
query={highlightQuery}
156+
activeOccurrence={getActiveOccurrence(matchNode.body(section))}
92157
className={"np-bodyText t3"}
93158
style={{
94159
fontWeight: fontWeight.regular,
95160
color: colors.fg.neutral,
96161
backgroundColor: colors.bg.neutralWeak,
97162
}}
98-
>
99-
{body}
100-
</text>
163+
/>
101164
)}
102165
{!error && !body && (
103166
<text

0 commit comments

Comments
 (0)