@gomjellie/lazyapi 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +162 -0
- package/dist/App.d.ts +9 -0
- package/dist/App.js +65 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +39 -0
- package/dist/components/common/ErrorMessage.d.ts +10 -0
- package/dist/components/common/ErrorMessage.js +17 -0
- package/dist/components/common/KeybindingFooter.d.ts +18 -0
- package/dist/components/common/KeybindingFooter.js +20 -0
- package/dist/components/common/LoadingSpinner.d.ts +9 -0
- package/dist/components/common/LoadingSpinner.js +15 -0
- package/dist/components/common/SearchInput.d.ts +14 -0
- package/dist/components/common/SearchInput.js +72 -0
- package/dist/components/common/StatusBar.d.ts +14 -0
- package/dist/components/common/StatusBar.js +36 -0
- package/dist/components/detail-view/DetailView.d.ts +5 -0
- package/dist/components/detail-view/DetailView.js +266 -0
- package/dist/components/detail-view/LeftPanel.d.ts +19 -0
- package/dist/components/detail-view/LeftPanel.js +363 -0
- package/dist/components/detail-view/ParameterForm.d.ts +17 -0
- package/dist/components/detail-view/ParameterForm.js +77 -0
- package/dist/components/detail-view/RightPanel.d.ts +22 -0
- package/dist/components/detail-view/RightPanel.js +251 -0
- package/dist/components/list-view/EndpointList.d.ts +12 -0
- package/dist/components/list-view/EndpointList.js +72 -0
- package/dist/components/list-view/Explorer.d.ts +13 -0
- package/dist/components/list-view/Explorer.js +83 -0
- package/dist/components/list-view/FilterBar.d.ts +12 -0
- package/dist/components/list-view/FilterBar.js +33 -0
- package/dist/components/list-view/ListView.d.ts +5 -0
- package/dist/components/list-view/ListView.js +304 -0
- package/dist/components/list-view/SearchBar.d.ts +11 -0
- package/dist/components/list-view/SearchBar.js +14 -0
- package/dist/hooks/useApiClient.d.ts +11 -0
- package/dist/hooks/useApiClient.js +34 -0
- package/dist/hooks/useKeyPress.d.ts +26 -0
- package/dist/hooks/useKeyPress.js +57 -0
- package/dist/hooks/useNavigation.d.ts +10 -0
- package/dist/hooks/useNavigation.js +33 -0
- package/dist/services/http-client.d.ts +21 -0
- package/dist/services/http-client.js +173 -0
- package/dist/services/openapi-parser.d.ts +47 -0
- package/dist/services/openapi-parser.js +318 -0
- package/dist/store/appSlice.d.ts +14 -0
- package/dist/store/appSlice.js +144 -0
- package/dist/store/hooks.d.ts +9 -0
- package/dist/store/hooks.js +7 -0
- package/dist/store/index.d.ts +12 -0
- package/dist/store/index.js +12 -0
- package/dist/types/app-state.d.ts +126 -0
- package/dist/types/app-state.js +4 -0
- package/dist/types/openapi.d.ts +86 -0
- package/dist/types/openapi.js +115 -0
- package/dist/utils/clipboard.d.ts +11 -0
- package/dist/utils/clipboard.js +26 -0
- package/dist/utils/formatters.d.ts +35 -0
- package/dist/utils/formatters.js +93 -0
- package/dist/utils/schema-formatter.d.ts +21 -0
- package/dist/utils/schema-formatter.js +301 -0
- package/dist/utils/validators.d.ts +16 -0
- package/dist/utils/validators.js +158 -0
- package/package.json +88 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API 상세 화면
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Box, Text, useStdout } from 'ink';
|
|
6
|
+
import { useAppSelector, useAppDispatch } from '../../store/hooks.js';
|
|
7
|
+
import { setMode, detailSetFocus, detailSetPanel, detailSetRequestTab, detailSetResponseTab, detailSetResponse, detailSetParam, } from '../../store/appSlice.js';
|
|
8
|
+
import { useNavigation } from '../../hooks/useNavigation.js';
|
|
9
|
+
import { useKeyPress } from '../../hooks/useKeyPress.js';
|
|
10
|
+
import { useApiClient } from '../../hooks/useApiClient.js';
|
|
11
|
+
import StatusBar from '../common/StatusBar.js';
|
|
12
|
+
import KeybindingFooter from '../common/KeybindingFooter.js';
|
|
13
|
+
import LeftPanel from './LeftPanel.js';
|
|
14
|
+
import RightPanel from './RightPanel.js';
|
|
15
|
+
import { getServerUrl } from '../../types/openapi.js';
|
|
16
|
+
import { setCommandInput } from '../../store/appSlice.js';
|
|
17
|
+
export default function DetailView() {
|
|
18
|
+
const detail = useAppSelector((state) => state.app.detail);
|
|
19
|
+
const document = useAppSelector((state) => state.app.document);
|
|
20
|
+
const mode = useAppSelector((state) => state.app.mode);
|
|
21
|
+
const commandInput = useAppSelector((state) => state.app.commandInput);
|
|
22
|
+
const dispatch = useAppDispatch();
|
|
23
|
+
const { goBack } = useNavigation();
|
|
24
|
+
const { executeRequest, loading } = useApiClient();
|
|
25
|
+
const { stdout } = useStdout();
|
|
26
|
+
// detail이 없는 경우를 대비한 기본값
|
|
27
|
+
const endpoint = detail?.endpoint;
|
|
28
|
+
const activePanel = detail?.activePanel || 'left';
|
|
29
|
+
const activeRequestTab = detail?.activeRequestTab || 'headers';
|
|
30
|
+
const activeResponseTab = detail?.activeResponseTab || 'body';
|
|
31
|
+
// 파라미터 필드 개수 계산
|
|
32
|
+
const pathParams = endpoint?.parameters.filter((p) => p.in === 'path') || [];
|
|
33
|
+
const queryParams = endpoint?.parameters.filter((p) => p.in === 'query') || [];
|
|
34
|
+
const headerParams = endpoint?.parameters.filter((p) => p.in === 'header') || [];
|
|
35
|
+
const bodyParams = endpoint?.parameters.filter((p) => p.in === 'body') || [];
|
|
36
|
+
const formDataParams = endpoint?.parameters.filter((p) => p.in === 'formData') || [];
|
|
37
|
+
const totalFields = pathParams.length +
|
|
38
|
+
queryParams.length +
|
|
39
|
+
headerParams.length +
|
|
40
|
+
bodyParams.length +
|
|
41
|
+
formDataParams.length;
|
|
42
|
+
const handleExecuteRequest = async () => {
|
|
43
|
+
if (!detail || !document)
|
|
44
|
+
return;
|
|
45
|
+
const baseURL = getServerUrl(document);
|
|
46
|
+
if (!baseURL)
|
|
47
|
+
return;
|
|
48
|
+
const response = await executeRequest(detail.endpoint, detail.parameterValues, detail.headers, detail.requestBody, baseURL);
|
|
49
|
+
if (response) {
|
|
50
|
+
// Detail 페이지에서 바로 결과 표시 (Result 페이지로 이동하지 않음)
|
|
51
|
+
dispatch(detailSetResponse(response));
|
|
52
|
+
// Response Body 탭으로 자동 전환
|
|
53
|
+
dispatch(detailSetResponseTab('body'));
|
|
54
|
+
// Response 패널로 포커스 이동
|
|
55
|
+
dispatch(detailSetPanel('response'));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const handleCommandSubmit = (cmd) => {
|
|
59
|
+
// 1. 종료 명령어 처리
|
|
60
|
+
if (cmd === 'q' || cmd === 'quit' || cmd === 'exit') {
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
// 2. 접두사 있는 명령어 처리 (pp1, qp1, h1, rb1, rs1, rq1...)
|
|
64
|
+
const match = cmd.match(/^(pp|qp|h|rb|rs|rq)(\d+)$/);
|
|
65
|
+
if (match) {
|
|
66
|
+
const type = match[1];
|
|
67
|
+
const index = parseInt(match[2], 10);
|
|
68
|
+
const targetIndex = index - 1;
|
|
69
|
+
if (type === 'pp') {
|
|
70
|
+
// Path Parameters
|
|
71
|
+
if (targetIndex >= 0 && targetIndex < pathParams.length) {
|
|
72
|
+
dispatch(detailSetFocus(targetIndex));
|
|
73
|
+
dispatch(detailSetPanel('left'));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (type === 'qp') {
|
|
77
|
+
// Query Parameters
|
|
78
|
+
const queryStartIndex = pathParams.length;
|
|
79
|
+
if (targetIndex >= 0 && targetIndex < queryParams.length) {
|
|
80
|
+
dispatch(detailSetFocus(queryStartIndex + targetIndex));
|
|
81
|
+
dispatch(detailSetPanel('left'));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else if (type === 'h') {
|
|
85
|
+
// Headers
|
|
86
|
+
const headerStartIndex = pathParams.length + queryParams.length;
|
|
87
|
+
if (targetIndex >= 0 && targetIndex < headerParams.length) {
|
|
88
|
+
dispatch(detailSetFocus(headerStartIndex + targetIndex));
|
|
89
|
+
dispatch(detailSetPanel('left'));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (type === 'rq') {
|
|
93
|
+
// Request Tabs
|
|
94
|
+
const tabs = ['headers', 'body', 'query'];
|
|
95
|
+
if (targetIndex >= 0 && targetIndex < tabs.length) {
|
|
96
|
+
dispatch(detailSetRequestTab(tabs[targetIndex]));
|
|
97
|
+
dispatch(detailSetPanel('request'));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (type === 'rb') {
|
|
101
|
+
// Request Body
|
|
102
|
+
const bodyStartIndex = pathParams.length + queryParams.length + headerParams.length;
|
|
103
|
+
// 만약 bodyParams가 있으면 그쪽으로, 아니면 requestBody(Raw)로
|
|
104
|
+
if (bodyParams.length > 0) {
|
|
105
|
+
if (targetIndex >= 0 && targetIndex < bodyParams.length) {
|
|
106
|
+
dispatch(detailSetFocus(bodyStartIndex + targetIndex));
|
|
107
|
+
dispatch(detailSetPanel('left'));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (endpoint?.requestBody) {
|
|
111
|
+
// Form Data가 있으면 그 뒤
|
|
112
|
+
const rawBodyIndex = bodyStartIndex + formDataParams.length;
|
|
113
|
+
if (targetIndex === 0) { // rb1 only for raw body
|
|
114
|
+
dispatch(detailSetFocus(rawBodyIndex));
|
|
115
|
+
dispatch(detailSetPanel('left'));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else if (type === 'rs') {
|
|
120
|
+
// Response Tabs
|
|
121
|
+
const tabs = [
|
|
122
|
+
'body',
|
|
123
|
+
'headers',
|
|
124
|
+
'cookies',
|
|
125
|
+
'curl',
|
|
126
|
+
];
|
|
127
|
+
if (targetIndex >= 0 && targetIndex < tabs.length) {
|
|
128
|
+
dispatch(detailSetResponseTab(tabs[targetIndex]));
|
|
129
|
+
dispatch(detailSetPanel('response'));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
dispatch(setMode('NORMAL'));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// 2. 숫자만 있는 경우 (기존 동작 호환: 현재 포커스된 영역의 아이템 선택)
|
|
136
|
+
const index = parseInt(cmd, 10);
|
|
137
|
+
if (!isNaN(index)) {
|
|
138
|
+
dispatch(setMode('NORMAL'));
|
|
139
|
+
const targetIndex = index - 1;
|
|
140
|
+
if (activePanel === 'left') {
|
|
141
|
+
if (targetIndex >= 0 && targetIndex < totalFields) {
|
|
142
|
+
dispatch(detailSetFocus(targetIndex));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else if (activePanel === 'request') {
|
|
146
|
+
const tabs = ['headers', 'body', 'query'];
|
|
147
|
+
if (targetIndex >= 0 && targetIndex < tabs.length) {
|
|
148
|
+
dispatch(detailSetRequestTab(tabs[targetIndex]));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else if (activePanel === 'response') {
|
|
152
|
+
const tabs = [
|
|
153
|
+
'body',
|
|
154
|
+
'headers',
|
|
155
|
+
'cookies',
|
|
156
|
+
'curl',
|
|
157
|
+
];
|
|
158
|
+
if (targetIndex >= 0 && targetIndex < tabs.length) {
|
|
159
|
+
dispatch(detailSetResponseTab(tabs[targetIndex]));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
dispatch(setMode('NORMAL'));
|
|
165
|
+
};
|
|
166
|
+
// 키보드 입력 처리 (NORMAL 모드)
|
|
167
|
+
useKeyPress({
|
|
168
|
+
j: () => {
|
|
169
|
+
if (activePanel === 'left' && totalFields > 0) {
|
|
170
|
+
// 다음 필드로
|
|
171
|
+
const nextIndex = Math.min(detail.focusedFieldIndex + 1, totalFields - 1);
|
|
172
|
+
dispatch(detailSetFocus(nextIndex));
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
k: () => {
|
|
176
|
+
if (activePanel === 'left' && totalFields > 0) {
|
|
177
|
+
// 이전 필드로
|
|
178
|
+
const prevIndex = Math.max(detail.focusedFieldIndex - 1, 0);
|
|
179
|
+
dispatch(detailSetFocus(prevIndex));
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
h: () => {
|
|
183
|
+
// 왼쪽 패널로 (어디서든)
|
|
184
|
+
dispatch(detailSetPanel('left'));
|
|
185
|
+
},
|
|
186
|
+
l: () => {
|
|
187
|
+
// 오른쪽(Request) 패널로
|
|
188
|
+
if (activePanel === 'left') {
|
|
189
|
+
dispatch(detailSetPanel('request'));
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
onTab: () => {
|
|
193
|
+
// 패널 순환: Left -> Request -> Response -> Left
|
|
194
|
+
if (activePanel === 'left') {
|
|
195
|
+
dispatch(detailSetPanel('request'));
|
|
196
|
+
}
|
|
197
|
+
else if (activePanel === 'request') {
|
|
198
|
+
dispatch(detailSetPanel('response'));
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
dispatch(detailSetPanel('left'));
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
i: () => {
|
|
205
|
+
// INSERT 모드로 전환 (왼쪽 패널에서만)
|
|
206
|
+
if (activePanel === 'left') {
|
|
207
|
+
dispatch(setMode('INSERT'));
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
u: () => {
|
|
211
|
+
// 뒤로 가기
|
|
212
|
+
goBack();
|
|
213
|
+
},
|
|
214
|
+
onReturn: () => {
|
|
215
|
+
// 요청 실행
|
|
216
|
+
handleExecuteRequest();
|
|
217
|
+
},
|
|
218
|
+
':': () => {
|
|
219
|
+
dispatch(setMode('COMMAND'));
|
|
220
|
+
},
|
|
221
|
+
}, mode === 'NORMAL');
|
|
222
|
+
// Command Mode Handler
|
|
223
|
+
useKeyPress({
|
|
224
|
+
onEscape: () => {
|
|
225
|
+
dispatch(setMode('NORMAL'));
|
|
226
|
+
dispatch(setCommandInput(''));
|
|
227
|
+
}
|
|
228
|
+
}, mode === 'COMMAND');
|
|
229
|
+
// INSERT 모드 키보드 입력 처리
|
|
230
|
+
useKeyPress({
|
|
231
|
+
onEscape: () => {
|
|
232
|
+
dispatch(setMode('NORMAL'));
|
|
233
|
+
},
|
|
234
|
+
}, mode === 'INSERT');
|
|
235
|
+
const keyBindings = mode === 'NORMAL'
|
|
236
|
+
? [
|
|
237
|
+
{ key: 'j/k', description: activePanel === 'left' ? 'Navigate' : 'Scroll' },
|
|
238
|
+
{ key: 'h/l', description: 'Panel' },
|
|
239
|
+
{ key: ':', description: 'Command' },
|
|
240
|
+
{ key: 'Tab', description: 'Cycle Panel' },
|
|
241
|
+
{ key: 'i', description: 'Insert' },
|
|
242
|
+
{ key: 'Enter', description: 'Execute' },
|
|
243
|
+
{ key: 'u', description: 'Back' },
|
|
244
|
+
]
|
|
245
|
+
: [
|
|
246
|
+
{ key: 'Tab', description: 'Autocomplete' },
|
|
247
|
+
{ key: 'Type', description: 'Edit Value' },
|
|
248
|
+
{ key: 'Esc', description: 'Normal Mode' },
|
|
249
|
+
];
|
|
250
|
+
const baseURL = document ? getServerUrl(document) || '' : '';
|
|
251
|
+
const terminalHeight = stdout?.rows || 24;
|
|
252
|
+
// detail 또는 endpoint가 없는 경우 에러 표시
|
|
253
|
+
if (!detail || !endpoint) {
|
|
254
|
+
return (React.createElement(Box, { flexDirection: "column", height: terminalHeight },
|
|
255
|
+
React.createElement(StatusBar, { title: "API DETAIL", currentPath: "", mode: mode, status: "ERROR" }),
|
|
256
|
+
React.createElement(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center" },
|
|
257
|
+
React.createElement(Text, { color: "red" }, "\uC0C1\uC138 \uC815\uBCF4\uB97C \uD45C\uC2DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.")),
|
|
258
|
+
React.createElement(KeybindingFooter, { bindings: [], mode: mode })));
|
|
259
|
+
}
|
|
260
|
+
return (React.createElement(Box, { flexDirection: "column", height: terminalHeight },
|
|
261
|
+
React.createElement(StatusBar, { title: "API DETAIL", currentPath: endpoint.path, mode: mode, status: loading ? 'LOADING...' : 'READY' }),
|
|
262
|
+
React.createElement(Box, { flexGrow: 1, overflow: "hidden" },
|
|
263
|
+
React.createElement(LeftPanel, { endpoint: endpoint, parameterValues: detail.parameterValues, focusedFieldIndex: detail.focusedFieldIndex, mode: mode, isFocused: activePanel === 'left', document: document, commandMode: mode === 'COMMAND', onParameterChange: (key, value) => dispatch(detailSetParam({ key, value })), onExitInsertMode: () => dispatch(setMode('NORMAL')) }),
|
|
264
|
+
React.createElement(RightPanel, { activeRequestTab: activeRequestTab, activeResponseTab: activeResponseTab, activePanel: activePanel, response: detail.response, endpoint: endpoint, parameterValues: detail.parameterValues, requestBody: detail.requestBody, requestHeaders: detail.headers, baseURL: baseURL, mode: mode, isLoading: loading, commandMode: mode === 'COMMAND' })),
|
|
265
|
+
React.createElement(KeybindingFooter, { bindings: keyBindings, mode: mode, commandInput: commandInput, onCommandChange: (value) => dispatch(setCommandInput(value)), onCommandSubmit: handleCommandSubmit })));
|
|
266
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Detail 좌측 패널 - 파라미터 입력
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Endpoint, OpenAPIDocument } from '../../types/openapi.js';
|
|
6
|
+
import { AppMode } from '../../types/app-state.js';
|
|
7
|
+
interface LeftPanelProps {
|
|
8
|
+
endpoint: Endpoint;
|
|
9
|
+
parameterValues: Record<string, string>;
|
|
10
|
+
focusedFieldIndex: number;
|
|
11
|
+
mode: AppMode;
|
|
12
|
+
isFocused: boolean;
|
|
13
|
+
document: OpenAPIDocument;
|
|
14
|
+
commandMode?: boolean;
|
|
15
|
+
onParameterChange: (key: string, value: string) => void;
|
|
16
|
+
onExitInsertMode?: () => void;
|
|
17
|
+
}
|
|
18
|
+
export default function LeftPanel({ endpoint, parameterValues, focusedFieldIndex, mode, isFocused, document, commandMode, onParameterChange, onExitInsertMode, }: LeftPanelProps): React.JSX.Element;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Detail 좌측 패널 - 파라미터 입력
|
|
3
|
+
*/
|
|
4
|
+
import React, { useState, useEffect, useLayoutEffect, useMemo } from 'react';
|
|
5
|
+
import { Box, Text, useStdout } from 'ink';
|
|
6
|
+
import SearchInput from '../common/SearchInput.js';
|
|
7
|
+
import { isReferenceObject, } from '../../types/openapi.js';
|
|
8
|
+
import { useKeyPress } from '../../hooks/useKeyPress.js';
|
|
9
|
+
import { formatSchemaFull } from '../../utils/schema-formatter.js';
|
|
10
|
+
function getMethodColor(method) {
|
|
11
|
+
switch (method) {
|
|
12
|
+
case 'GET':
|
|
13
|
+
return 'blue';
|
|
14
|
+
case 'POST':
|
|
15
|
+
return 'green';
|
|
16
|
+
case 'PUT':
|
|
17
|
+
return 'yellow';
|
|
18
|
+
case 'DELETE':
|
|
19
|
+
return 'red';
|
|
20
|
+
case 'PATCH':
|
|
21
|
+
return 'magenta';
|
|
22
|
+
default:
|
|
23
|
+
return 'white';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export default function LeftPanel({ endpoint, parameterValues, focusedFieldIndex, mode, isFocused, document, commandMode = false, onParameterChange, onExitInsertMode, }) {
|
|
27
|
+
const { stdout } = useStdout();
|
|
28
|
+
const [localValue, setLocalValue] = useState('');
|
|
29
|
+
const [expandedSchemas, setExpandedSchemas] = useState(new Set());
|
|
30
|
+
const [schemaScrollOffset, setSchemaScrollOffset] = useState(0);
|
|
31
|
+
// Flat Items Memoization
|
|
32
|
+
const { flatItems, paramIndexToFlatIndex } = useMemo(() => {
|
|
33
|
+
// 파라미터 분류
|
|
34
|
+
const pathParams = endpoint.parameters.filter((p) => p.in === 'path');
|
|
35
|
+
const queryParams = endpoint.parameters.filter((p) => p.in === 'query');
|
|
36
|
+
const headerParams = endpoint.parameters.filter((p) => p.in === 'header');
|
|
37
|
+
const bodyParams = endpoint.parameters.filter((p) => p.in === 'body');
|
|
38
|
+
const formDataParams = endpoint.parameters.filter((p) => p.in === 'formData');
|
|
39
|
+
const hasRequestBody = endpoint.requestBody && bodyParams.length === 0;
|
|
40
|
+
const items = [];
|
|
41
|
+
const indexMap = [];
|
|
42
|
+
// 1. Title
|
|
43
|
+
items.push({ type: 'title', endpoint });
|
|
44
|
+
// Helper
|
|
45
|
+
const addParams = (params, sectionTitle, startIndex) => {
|
|
46
|
+
if (params.length > 0) {
|
|
47
|
+
items.push({ type: 'section', text: sectionTitle });
|
|
48
|
+
params.forEach((p, i) => {
|
|
49
|
+
const globalIndex = startIndex + i;
|
|
50
|
+
indexMap[globalIndex] = items.length;
|
|
51
|
+
items.push({ type: 'param', param: p, globalIndex });
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
let currentIndex = 0;
|
|
56
|
+
addParams(pathParams, '▸ Path Parameters', currentIndex);
|
|
57
|
+
currentIndex += pathParams.length;
|
|
58
|
+
addParams(queryParams, '▸ Query Parameters', currentIndex);
|
|
59
|
+
currentIndex += queryParams.length;
|
|
60
|
+
addParams(headerParams, '▸ Headers', currentIndex);
|
|
61
|
+
currentIndex += headerParams.length;
|
|
62
|
+
addParams(bodyParams, '▸ Request Body', currentIndex);
|
|
63
|
+
currentIndex += bodyParams.length;
|
|
64
|
+
addParams(formDataParams, '▸ Form Data', currentIndex);
|
|
65
|
+
currentIndex += formDataParams.length;
|
|
66
|
+
if (hasRequestBody) {
|
|
67
|
+
items.push({ type: 'section', text: '▸ Request Body' });
|
|
68
|
+
indexMap[currentIndex] = items.length;
|
|
69
|
+
items.push({ type: 'requestBody', endpoint, globalIndex: currentIndex });
|
|
70
|
+
}
|
|
71
|
+
return { flatItems: items, paramIndexToFlatIndex: indexMap };
|
|
72
|
+
}, [endpoint]);
|
|
73
|
+
// 스크롤 로직
|
|
74
|
+
const totalHeight = (stdout?.rows || 24) - 4;
|
|
75
|
+
const contentHeight = Math.max(totalHeight - 2, 1);
|
|
76
|
+
const visibleItemCount = Math.floor(contentHeight / 2); // 추정치
|
|
77
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
78
|
+
useLayoutEffect(() => {
|
|
79
|
+
const targetFlatIndex = paramIndexToFlatIndex[focusedFieldIndex];
|
|
80
|
+
if (targetFlatIndex === undefined)
|
|
81
|
+
return;
|
|
82
|
+
// 스크롤 위치 조정은 DOM 업데이트 직후에 실행되어야 하므로 useLayoutEffect 사용
|
|
83
|
+
// 이는 화면 흔들림을 방지하기 위한 필수적인 작업입니다
|
|
84
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
85
|
+
setScrollOffset((currentOffset) => {
|
|
86
|
+
// 현재 보이는 영역 내에 있으면 스크롤 변경하지 않음 (화면 흔들림 방지)
|
|
87
|
+
if (targetFlatIndex >= currentOffset && targetFlatIndex < currentOffset + visibleItemCount) {
|
|
88
|
+
return currentOffset;
|
|
89
|
+
}
|
|
90
|
+
// 포커스가 보이는 영역 밖에 있을 때만 스크롤 조정
|
|
91
|
+
if (targetFlatIndex < currentOffset) {
|
|
92
|
+
// 포커스가 위에 있으면 포커스를 상단에 맞춤
|
|
93
|
+
return Math.max(0, targetFlatIndex);
|
|
94
|
+
}
|
|
95
|
+
else if (targetFlatIndex >= currentOffset + visibleItemCount) {
|
|
96
|
+
// 포커스가 아래에 있으면 포커스를 하단에 맞춤
|
|
97
|
+
return Math.max(0, targetFlatIndex - visibleItemCount + 1);
|
|
98
|
+
}
|
|
99
|
+
return currentOffset;
|
|
100
|
+
});
|
|
101
|
+
}, [focusedFieldIndex, visibleItemCount, paramIndexToFlatIndex]);
|
|
102
|
+
// 포커스 변경시 로컬 값 업데이트
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const flatIndex = paramIndexToFlatIndex[focusedFieldIndex];
|
|
105
|
+
const item = flatItems[flatIndex];
|
|
106
|
+
if (item && item.type === 'param') {
|
|
107
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
108
|
+
setLocalValue(parameterValues[item.param.name] || '');
|
|
109
|
+
}
|
|
110
|
+
}, [focusedFieldIndex, parameterValues, paramIndexToFlatIndex, flatItems]);
|
|
111
|
+
// Handlers
|
|
112
|
+
const handleExpandSchema = () => {
|
|
113
|
+
if (!isFocused || mode !== 'NORMAL')
|
|
114
|
+
return;
|
|
115
|
+
const flatIndex = paramIndexToFlatIndex[focusedFieldIndex];
|
|
116
|
+
const item = flatItems[flatIndex];
|
|
117
|
+
if (item?.type === 'requestBody') {
|
|
118
|
+
setExpandedSchemas((prev) => {
|
|
119
|
+
const next = new Set(prev);
|
|
120
|
+
next.add('__requestBody__');
|
|
121
|
+
return next;
|
|
122
|
+
});
|
|
123
|
+
setSchemaScrollOffset(0);
|
|
124
|
+
}
|
|
125
|
+
else if (item?.type === 'param' &&
|
|
126
|
+
item.param.schema &&
|
|
127
|
+
isReferenceObject(item.param.schema)) {
|
|
128
|
+
setExpandedSchemas((prev) => {
|
|
129
|
+
const next = new Set(prev);
|
|
130
|
+
next.add(item.param.name);
|
|
131
|
+
return next;
|
|
132
|
+
});
|
|
133
|
+
setSchemaScrollOffset(0);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const handleCollapseSchema = () => {
|
|
137
|
+
if (!isFocused || mode !== 'NORMAL')
|
|
138
|
+
return;
|
|
139
|
+
const flatIndex = paramIndexToFlatIndex[focusedFieldIndex];
|
|
140
|
+
const item = flatItems[flatIndex];
|
|
141
|
+
if (item?.type === 'requestBody') {
|
|
142
|
+
setExpandedSchemas((prev) => {
|
|
143
|
+
const next = new Set(prev);
|
|
144
|
+
next.delete('__requestBody__');
|
|
145
|
+
return next;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
else if (item?.type === 'param') {
|
|
149
|
+
setExpandedSchemas((prev) => {
|
|
150
|
+
const next = new Set(prev);
|
|
151
|
+
next.delete(item.param.name);
|
|
152
|
+
return next;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const handleSchemaScrollDown = () => {
|
|
157
|
+
if (!isFocused || mode !== 'NORMAL')
|
|
158
|
+
return;
|
|
159
|
+
const flatIndex = paramIndexToFlatIndex[focusedFieldIndex];
|
|
160
|
+
const item = flatItems[flatIndex];
|
|
161
|
+
const maxHeight = Math.floor((stdout?.rows || 24) * 0.3);
|
|
162
|
+
if (item?.type === 'requestBody' && expandedSchemas.has('__requestBody__')) {
|
|
163
|
+
if (endpoint.requestBody?.schema) {
|
|
164
|
+
const lines = formatSchemaFull(endpoint.requestBody.schema, document, 3);
|
|
165
|
+
setSchemaScrollOffset((prev) => Math.min(prev + 1, Math.max(0, lines.length - maxHeight)));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else if (item?.type === 'param' && expandedSchemas.has(item.param.name)) {
|
|
169
|
+
if (item.param.schema && isReferenceObject(item.param.schema)) {
|
|
170
|
+
const lines = formatSchemaFull(item.param.schema, document, 2);
|
|
171
|
+
setSchemaScrollOffset((prev) => Math.min(prev + 1, Math.max(0, lines.length - maxHeight)));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const handleSchemaScrollUp = () => {
|
|
176
|
+
setSchemaScrollOffset((prev) => Math.max(prev - 1, 0));
|
|
177
|
+
};
|
|
178
|
+
useKeyPress({
|
|
179
|
+
'>': handleExpandSchema,
|
|
180
|
+
'<': handleCollapseSchema,
|
|
181
|
+
j: handleSchemaScrollDown,
|
|
182
|
+
k: handleSchemaScrollUp,
|
|
183
|
+
}, mode === 'NORMAL' && isFocused);
|
|
184
|
+
// Render Helpers
|
|
185
|
+
const renderSchema = (ref) => {
|
|
186
|
+
const schema = { $ref: ref };
|
|
187
|
+
const lines = formatSchemaFull(schema, document, 2);
|
|
188
|
+
const maxHeight = Math.floor((stdout?.rows || 24) * 0.3);
|
|
189
|
+
const visibleLines = lines.slice(schemaScrollOffset, schemaScrollOffset + maxHeight);
|
|
190
|
+
return (React.createElement(Box, { marginTop: 1, borderStyle: "single", borderColor: "cyan", paddingX: 1, flexDirection: "column" },
|
|
191
|
+
visibleLines.map((line, idx) => (React.createElement(Text, { key: idx, color: "cyan", dimColor: true }, line))),
|
|
192
|
+
lines.length > maxHeight && (React.createElement(Text, { color: "gray", dimColor: true },
|
|
193
|
+
schemaScrollOffset + 1,
|
|
194
|
+
"-",
|
|
195
|
+
Math.min(schemaScrollOffset + maxHeight, lines.length),
|
|
196
|
+
" of",
|
|
197
|
+
' ',
|
|
198
|
+
lines.length,
|
|
199
|
+
" lines"))));
|
|
200
|
+
};
|
|
201
|
+
const renderRequestBodySchema = () => {
|
|
202
|
+
if (!endpoint.requestBody?.schema)
|
|
203
|
+
return null;
|
|
204
|
+
const lines = formatSchemaFull(endpoint.requestBody.schema, document, 3);
|
|
205
|
+
const maxHeight = Math.floor((stdout?.rows || 24) * 0.3);
|
|
206
|
+
const visibleLines = lines.slice(schemaScrollOffset, schemaScrollOffset + maxHeight);
|
|
207
|
+
return (React.createElement(Box, { marginTop: 1, borderStyle: "single", borderColor: "cyan", paddingX: 1, flexDirection: "column" },
|
|
208
|
+
visibleLines.map((line, idx) => (React.createElement(Text, { key: idx, color: "cyan", dimColor: true }, line))),
|
|
209
|
+
lines.length > maxHeight && (React.createElement(Text, { color: "gray", dimColor: true },
|
|
210
|
+
schemaScrollOffset + 1,
|
|
211
|
+
"-",
|
|
212
|
+
Math.min(schemaScrollOffset + maxHeight, lines.length),
|
|
213
|
+
" of",
|
|
214
|
+
' ',
|
|
215
|
+
lines.length,
|
|
216
|
+
" lines"))));
|
|
217
|
+
};
|
|
218
|
+
// Rendering
|
|
219
|
+
const visibleItems = flatItems.slice(scrollOffset, scrollOffset + visibleItemCount);
|
|
220
|
+
const pathParamCount = endpoint.parameters.filter((p) => p.in === 'path').length;
|
|
221
|
+
const headerParamCount = endpoint.parameters.filter((p) => p.in === 'header').length;
|
|
222
|
+
const queryParamCount = endpoint.parameters.filter((p) => p.in === 'query').length;
|
|
223
|
+
const bodyStartIndex = pathParamCount + queryParamCount + headerParamCount;
|
|
224
|
+
return (React.createElement(Box, { flexDirection: "column", width: "50%", height: totalHeight, borderStyle: "single", borderColor: isFocused ? 'green' : 'gray', paddingX: 1 }, visibleItems.map((item, idx) => {
|
|
225
|
+
const key = `item-${scrollOffset + idx}`;
|
|
226
|
+
if (item.type === 'title') {
|
|
227
|
+
return (React.createElement(Box, { key: key, flexDirection: "column", marginBottom: 1 },
|
|
228
|
+
React.createElement(Box, null,
|
|
229
|
+
React.createElement(Text, { bold: true, color: getMethodColor(item.endpoint.method) },
|
|
230
|
+
"[",
|
|
231
|
+
item.endpoint.method,
|
|
232
|
+
"]"),
|
|
233
|
+
React.createElement(Text, { bold: true, color: "green" },
|
|
234
|
+
' ',
|
|
235
|
+
item.endpoint.path)),
|
|
236
|
+
(item.endpoint.summary || item.endpoint.description) && (React.createElement(Text, { dimColor: true }, item.endpoint.summary || item.endpoint.description))));
|
|
237
|
+
}
|
|
238
|
+
if (item.type === 'section') {
|
|
239
|
+
return (React.createElement(Box, { key: key, marginTop: 1, marginBottom: 0 },
|
|
240
|
+
React.createElement(Text, { bold: true, color: "green" }, item.text)));
|
|
241
|
+
}
|
|
242
|
+
if (item.type === 'param') {
|
|
243
|
+
const isFieldFocused = item.globalIndex === focusedFieldIndex && isFocused;
|
|
244
|
+
const value = parameterValues[item.param.name] || '';
|
|
245
|
+
let typeInfo = '';
|
|
246
|
+
let refInfo = '';
|
|
247
|
+
if (item.param.schema) {
|
|
248
|
+
if (isReferenceObject(item.param.schema)) {
|
|
249
|
+
refInfo = item.param.schema.$ref.split('/').pop() || item.param.schema.$ref;
|
|
250
|
+
typeInfo = 'object';
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const schemaType = item.param.schema.type;
|
|
254
|
+
typeInfo = Array.isArray(schemaType) ? schemaType.join(' | ') : schemaType || '';
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const hasSchema = !!(refInfo &&
|
|
258
|
+
item.param.schema &&
|
|
259
|
+
isReferenceObject(item.param.schema));
|
|
260
|
+
const isExpanded = expandedSchemas.has(item.param.name);
|
|
261
|
+
// Command Hint Calculation
|
|
262
|
+
let commandHint = '';
|
|
263
|
+
if (commandMode) {
|
|
264
|
+
if (item.param.in === 'path') {
|
|
265
|
+
const ppIndex = item.globalIndex + 1;
|
|
266
|
+
commandHint = `pp${ppIndex}`;
|
|
267
|
+
}
|
|
268
|
+
else if (item.param.in === 'query') {
|
|
269
|
+
const qpIndex = item.globalIndex - pathParamCount + 1;
|
|
270
|
+
commandHint = `qp${qpIndex}`;
|
|
271
|
+
}
|
|
272
|
+
else if (item.param.in === 'header') {
|
|
273
|
+
const hIndex = item.globalIndex - (pathParamCount + queryParamCount) + 1;
|
|
274
|
+
commandHint = `h${hIndex}`;
|
|
275
|
+
}
|
|
276
|
+
else if (item.param.in === 'body') {
|
|
277
|
+
const rbIndex = item.globalIndex - bodyStartIndex + 1;
|
|
278
|
+
commandHint = `rb${rbIndex}`;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return (React.createElement(Box, { key: key, flexDirection: "column" },
|
|
282
|
+
React.createElement(Box, { marginLeft: 1, flexDirection: "row" },
|
|
283
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
284
|
+
commandMode && (React.createElement(Box, { width: 6 },
|
|
285
|
+
React.createElement(Text, { color: "yellow" },
|
|
286
|
+
"[",
|
|
287
|
+
commandHint,
|
|
288
|
+
"]"))),
|
|
289
|
+
React.createElement(Text, { color: isFieldFocused ? 'green' : 'white', bold: isFieldFocused }, item.param.name),
|
|
290
|
+
React.createElement(Text, { color: "gray" },
|
|
291
|
+
" (",
|
|
292
|
+
item.param.in,
|
|
293
|
+
")"),
|
|
294
|
+
item.param.required && React.createElement(Text, { color: "red" }, " *"),
|
|
295
|
+
typeInfo && React.createElement(Text, { color: "cyan" },
|
|
296
|
+
" : ",
|
|
297
|
+
typeInfo)),
|
|
298
|
+
React.createElement(Box, { marginLeft: 2, flexGrow: 1 }, mode === 'INSERT' && isFieldFocused ? (React.createElement(SearchInput, { value: localValue, onChange: (val) => {
|
|
299
|
+
setLocalValue(val);
|
|
300
|
+
onParameterChange(item.param.name, val);
|
|
301
|
+
}, suggestions: [], placeholder: "", focus: true, onSubmit: onExitInsertMode })) : (React.createElement(Text, { color: value ? 'white' : 'gray', backgroundColor: isFieldFocused ? 'green' : undefined, bold: isFieldFocused && !value }, value || (isFieldFocused ? ' (empty) ' : '(empty)'))))),
|
|
302
|
+
item.param.description && (React.createElement(Box, { marginLeft: 2, marginTop: 0 },
|
|
303
|
+
React.createElement(Text, { color: "gray", dimColor: true }, item.param.description))),
|
|
304
|
+
(() => {
|
|
305
|
+
if (!isFieldFocused)
|
|
306
|
+
return null;
|
|
307
|
+
let enumValues = [];
|
|
308
|
+
if (item.param.schema && !isReferenceObject(item.param.schema)) {
|
|
309
|
+
if (item.param.schema.enum) {
|
|
310
|
+
enumValues = item.param.schema.enum;
|
|
311
|
+
}
|
|
312
|
+
else if (item.param.schema.type === 'array' &&
|
|
313
|
+
item.param.schema.items &&
|
|
314
|
+
!isReferenceObject(item.param.schema.items) &&
|
|
315
|
+
item.param.schema.items.enum) {
|
|
316
|
+
enumValues = item.param.schema.items.enum;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (enumValues.length > 0) {
|
|
320
|
+
return (React.createElement(Box, { marginLeft: 2, marginTop: 0, flexDirection: "column" },
|
|
321
|
+
React.createElement(Text, { color: "yellow", dimColor: true },
|
|
322
|
+
"Available values: ",
|
|
323
|
+
enumValues.join(', ')),
|
|
324
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "(Tab to autocomplete)")));
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
})(),
|
|
328
|
+
hasSchema && !isExpanded && (React.createElement(Box, { marginLeft: 2, marginTop: 0 },
|
|
329
|
+
React.createElement(Text, { color: "yellow", dimColor: true },
|
|
330
|
+
"Press > to view ",
|
|
331
|
+
refInfo,
|
|
332
|
+
" schema"))),
|
|
333
|
+
hasSchema && isExpanded && isReferenceObject(item.param.schema) && (React.createElement(React.Fragment, null,
|
|
334
|
+
React.createElement(Box, { marginLeft: 2, marginTop: 0 },
|
|
335
|
+
React.createElement(Text, { color: "yellow", dimColor: true },
|
|
336
|
+
"Schema: ",
|
|
337
|
+
refInfo,
|
|
338
|
+
" (Press < to collapse)")),
|
|
339
|
+
renderSchema(item.param.schema.$ref)))));
|
|
340
|
+
}
|
|
341
|
+
if (item.type === 'requestBody') {
|
|
342
|
+
const isFieldFocused = item.globalIndex === focusedFieldIndex && isFocused;
|
|
343
|
+
const isExpanded = expandedSchemas.has('__requestBody__');
|
|
344
|
+
const hasSchema = !!endpoint.requestBody?.schema;
|
|
345
|
+
const commandHint = commandMode ? 'rb1' : '';
|
|
346
|
+
return (React.createElement(Box, { key: key, flexDirection: "column" },
|
|
347
|
+
React.createElement(Box, { flexDirection: "row", backgroundColor: isFieldFocused ? 'green' : undefined, marginLeft: 1 },
|
|
348
|
+
commandMode && (React.createElement(Box, { width: 6 },
|
|
349
|
+
React.createElement(Text, { color: "yellow" },
|
|
350
|
+
"[",
|
|
351
|
+
commandHint,
|
|
352
|
+
"]"))),
|
|
353
|
+
React.createElement(Text, { color: isFieldFocused ? 'black' : 'gray' }, item.endpoint.requestBody?.description || 'Request Body Content')),
|
|
354
|
+
hasSchema && !isExpanded && (React.createElement(Box, { marginLeft: 1, marginTop: 0 },
|
|
355
|
+
React.createElement(Text, { color: "yellow", dimColor: true }, "Press > to view schema details"))),
|
|
356
|
+
hasSchema && isExpanded && (React.createElement(React.Fragment, null,
|
|
357
|
+
React.createElement(Box, { marginLeft: 1, marginTop: 0 },
|
|
358
|
+
React.createElement(Text, { color: "yellow", dimColor: true }, "Schema (Press < to collapse)")),
|
|
359
|
+
renderRequestBodySchema()))));
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
})));
|
|
363
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 파라미터 입력 폼 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Endpoint } from '../../types/openapi.js';
|
|
6
|
+
import { AppMode } from '../../types/app-state.js';
|
|
7
|
+
interface ParameterFormProps {
|
|
8
|
+
endpoint: Endpoint;
|
|
9
|
+
parameterValues: Record<string, string>;
|
|
10
|
+
requestBody: string;
|
|
11
|
+
focusedFieldIndex: number;
|
|
12
|
+
mode: AppMode;
|
|
13
|
+
onParameterChange: (key: string, value: string) => void;
|
|
14
|
+
onBodyChange: (body: string) => void;
|
|
15
|
+
}
|
|
16
|
+
export default function ParameterForm({ endpoint, parameterValues, requestBody, focusedFieldIndex, mode, onParameterChange, onBodyChange, }: ParameterFormProps): React.JSX.Element;
|
|
17
|
+
export {};
|