@gomjellie/lazyapi 0.0.2 → 0.0.4
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/README.md +6 -7
- package/dist/App.js +1 -1
- package/dist/components/common/LoadingSpinner.js +8 -4
- package/dist/components/common/StatusBar.js +3 -0
- package/dist/components/detail-view/DetailView.js +38 -38
- package/dist/components/detail-view/LeftPanel.d.ts +4 -1
- package/dist/components/detail-view/LeftPanel.js +402 -72
- package/dist/components/detail-view/RightPanel.d.ts +4 -2
- package/dist/components/detail-view/RightPanel.js +142 -43
- package/dist/components/list-view/EndpointList.js +16 -11
- package/dist/components/list-view/ListView.js +41 -59
- package/dist/components/list-view/MethodsBar.d.ts +14 -0
- package/dist/components/list-view/MethodsBar.js +36 -0
- package/dist/hooks/useKeyPress.js +66 -23
- package/dist/services/openapi-parser.d.ts +10 -2
- package/dist/services/openapi-parser.js +104 -10
- package/dist/store/appSlice.d.ts +3 -3
- package/dist/store/appSlice.js +49 -5
- package/dist/store/hooks.d.ts +1 -1
- package/dist/store/index.d.ts +3 -3
- package/dist/store/navigationActions.d.ts +9 -0
- package/dist/store/navigationActions.js +227 -0
- package/dist/types/app-state.d.ts +17 -4
- package/dist/types/openapi.d.ts +18 -1
- package/dist/utils/schema-formatter.js +19 -5
- package/dist/utils/schema-scaffolder.d.ts +7 -0
- package/dist/utils/schema-scaffolder.js +61 -0
- package/package.json +5 -1
- package/dist/components/list-view/FilterBar.d.ts +0 -12
- package/dist/components/list-view/FilterBar.js +0 -33
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
* API Detail 우측 패널 - Request/Response 분할
|
|
3
3
|
*/
|
|
4
4
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
5
|
-
import { Box, Text, useStdout } from 'ink';
|
|
5
|
+
import { Box, Text, useStdout, useStdin } from 'ink';
|
|
6
6
|
import { useKeyPress } from '../../hooks/useKeyPress.js';
|
|
7
7
|
import { useAppDispatch } from '../../store/hooks.js';
|
|
8
|
-
import { detailSetPanel } from '../../store/appSlice.js';
|
|
8
|
+
import { detailSetPanel, detailSetBody, detailSetSubFocus, setMode } from '../../store/appSlice.js';
|
|
9
|
+
import { spawnSync } from 'child_process';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { scaffoldSchema } from '../../utils/schema-scaffolder.js';
|
|
9
14
|
import Spinner from 'ink-spinner';
|
|
10
|
-
const TabHeader = ({ tabs, active, isFocused, shortcutPrefix, commandMode
|
|
15
|
+
const TabHeader = ({ tabs, active, isFocused, shortcutPrefix, commandMode }) => (React.createElement(Box, { marginBottom: 0, borderStyle: "single", borderBottom: false, borderTop: false, borderLeft: false, borderRight: false, gap: 1 }, tabs.map((t, index) => {
|
|
11
16
|
let showHint = false;
|
|
12
17
|
let hintText = '';
|
|
13
18
|
const shouldShowGlobal = commandMode && !!shortcutPrefix;
|
|
@@ -22,12 +27,16 @@ const TabHeader = ({ tabs, active, isFocused, shortcutPrefix, commandMode, }) =>
|
|
|
22
27
|
showHint = true;
|
|
23
28
|
hintText = `[${index + 1}] `;
|
|
24
29
|
}
|
|
30
|
+
const isActive = t.id === active;
|
|
31
|
+
const showIndicator = isFocused && isActive;
|
|
25
32
|
return (React.createElement(Box, { key: t.id },
|
|
26
33
|
showHint && React.createElement(Text, { color: "yellow" }, hintText),
|
|
27
|
-
React.createElement(Text, {
|
|
34
|
+
React.createElement(Text, { color: "yellow" }, showIndicator ? '>' : ' '),
|
|
35
|
+
React.createElement(Text, { bold: isActive, underline: showIndicator, color: isActive ? 'black' : 'gray', backgroundColor: isActive ? 'green' : undefined }, t.label + ' ')));
|
|
28
36
|
})));
|
|
29
|
-
export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, response, endpoint, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode = false, }) {
|
|
37
|
+
export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, subFocus, response, endpoint, document, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode = false, }) {
|
|
30
38
|
const { stdout } = useStdout();
|
|
39
|
+
const { isRawModeSupported, setRawMode } = useStdin();
|
|
31
40
|
const dispatch = useAppDispatch();
|
|
32
41
|
// 높이 계산
|
|
33
42
|
const totalHeight = (stdout?.rows || 24) - 4;
|
|
@@ -37,9 +46,28 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
37
46
|
// -- Request Box State --
|
|
38
47
|
const [reqScroll, setReqScroll] = useState(0);
|
|
39
48
|
useEffect(() => {
|
|
40
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
41
49
|
setReqScroll(0);
|
|
42
50
|
}, [activeRequestTab, endpoint]);
|
|
51
|
+
// Request Body 자동 스캐폴딩
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (activeRequestTab === 'body' && activePanel === 'request' && !requestBody) {
|
|
54
|
+
// 1. requestBody 필드 (OpenAPI 3.0)
|
|
55
|
+
if (endpoint.requestBody?.schema) {
|
|
56
|
+
const scaffolded = scaffoldSchema(endpoint.requestBody.schema, document);
|
|
57
|
+
const value = JSON.stringify(scaffolded, null, 2);
|
|
58
|
+
dispatch(detailSetBody(value));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// 2. in: body 파라미터 (Swagger 2.0)
|
|
62
|
+
const bodyParam = endpoint.parameters.find(p => p.in === 'body');
|
|
63
|
+
if (bodyParam && bodyParam.schema) {
|
|
64
|
+
const scaffolded = scaffoldSchema(bodyParam.schema, document);
|
|
65
|
+
const value = JSON.stringify(scaffolded, null, 2);
|
|
66
|
+
dispatch(detailSetBody(value));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}, [activeRequestTab, activePanel, requestBody, endpoint, document, dispatch]);
|
|
43
71
|
// Request Content Lines 계산
|
|
44
72
|
const getRequestLines = () => {
|
|
45
73
|
if (activeRequestTab === 'headers') {
|
|
@@ -59,41 +87,91 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
59
87
|
// Request Scroll Handler (j/k)
|
|
60
88
|
useKeyPress({
|
|
61
89
|
j: () => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
90
|
+
setReqScroll((prev) => {
|
|
91
|
+
const maxScroll = Math.max(0, reqTotalLines - contentHeight);
|
|
92
|
+
if (prev < maxScroll) {
|
|
93
|
+
return Math.min(prev + 1, maxScroll);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// 스크롤이 끝에 도달하면 Response 패널로 이동
|
|
97
|
+
dispatch(detailSetPanel('response'));
|
|
98
|
+
dispatch(detailSetSubFocus('TITLE'));
|
|
99
|
+
return prev;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
70
102
|
},
|
|
71
103
|
k: () => {
|
|
72
|
-
setReqScroll((prev) =>
|
|
104
|
+
setReqScroll((prev) => {
|
|
105
|
+
if (prev > 0)
|
|
106
|
+
return prev - 1;
|
|
107
|
+
dispatch(detailSetSubFocus('TABS'));
|
|
108
|
+
return 0;
|
|
109
|
+
});
|
|
73
110
|
},
|
|
74
|
-
}, mode === 'NORMAL' && activePanel === 'request');
|
|
111
|
+
}, mode === 'NORMAL' && activePanel === 'request' && subFocus === 'CONTENT');
|
|
112
|
+
const openExternalEditor = () => {
|
|
113
|
+
try {
|
|
114
|
+
const tempFile = path.join(os.tmpdir(), `lazyapi-body-${Date.now()}.json`);
|
|
115
|
+
fs.writeFileSync(tempFile, requestBody || '');
|
|
116
|
+
const editor = process.env.EDITOR || 'vim';
|
|
117
|
+
// Ink의 Raw 모드를 해제하고 stdin을 일시 중지하여 외부 에디터에 제어권 이양
|
|
118
|
+
if (isRawModeSupported) {
|
|
119
|
+
setRawMode(false);
|
|
120
|
+
}
|
|
121
|
+
process.stdin.pause();
|
|
122
|
+
try {
|
|
123
|
+
// 동기적으로 실행하여 에디터 종료까지 대기
|
|
124
|
+
spawnSync(editor, [tempFile], {
|
|
125
|
+
stdio: 'inherit',
|
|
126
|
+
});
|
|
127
|
+
if (fs.existsSync(tempFile)) {
|
|
128
|
+
const content = fs.readFileSync(tempFile, 'utf-8');
|
|
129
|
+
dispatch(detailSetBody(content));
|
|
130
|
+
fs.unlinkSync(tempFile);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (innerError) {
|
|
134
|
+
console.error('Error executing editor:', innerError);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
// 에디터 종료 후 stdin 재개 및 Raw 모드 복구
|
|
138
|
+
process.stdin.resume();
|
|
139
|
+
if (isRawModeSupported) {
|
|
140
|
+
setRawMode(true);
|
|
141
|
+
}
|
|
142
|
+
dispatch(setMode('NORMAL'));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
console.error('Failed to open editor', e);
|
|
147
|
+
dispatch(setMode('NORMAL'));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
// e 키: Request Body 탭이 활성화되어 있으면 항상 에디터 오픈 (NORMAL/INSERT 모두)
|
|
151
|
+
useKeyPress({
|
|
152
|
+
e: openExternalEditor,
|
|
153
|
+
}, activePanel === 'request' && activeRequestTab === 'body');
|
|
75
154
|
// -- Response Box State --
|
|
76
155
|
const [resScroll, setResScroll] = useState(0);
|
|
77
156
|
useEffect(() => {
|
|
78
157
|
// 탭이 변경될 때만 스크롤 리셋 (response 변경 시에는 리셋하지 않음)
|
|
79
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
80
158
|
setResScroll(0);
|
|
81
159
|
}, [activeResponseTab]);
|
|
82
160
|
// Curl Command 생성 (메모이제이션)
|
|
83
161
|
const curlCommand = useMemo(() => {
|
|
84
|
-
let
|
|
162
|
+
let pathStr = endpoint.path;
|
|
85
163
|
endpoint.parameters
|
|
86
164
|
.filter((p) => p.in === 'path')
|
|
87
165
|
.forEach((param) => {
|
|
88
166
|
const value = parameterValues[param.name] || '';
|
|
89
|
-
|
|
167
|
+
pathStr = pathStr.replace(`{${param.name}}`, value);
|
|
90
168
|
});
|
|
91
169
|
const queryParams = endpoint.parameters
|
|
92
170
|
.filter((p) => p.in === 'query')
|
|
93
171
|
.filter((param) => parameterValues[param.name])
|
|
94
172
|
.map((param) => `${param.name}=${encodeURIComponent(parameterValues[param.name])}`)
|
|
95
173
|
.join('&');
|
|
96
|
-
const url = queryParams ? `${baseURL}${
|
|
174
|
+
const url = queryParams ? `${baseURL}${pathStr}?${queryParams}` : `${baseURL}${pathStr}`;
|
|
97
175
|
let headerStr = '';
|
|
98
176
|
Object.entries(requestHeaders).forEach(([key, val]) => {
|
|
99
177
|
headerStr += ` -H '${key}: ${val}'`;
|
|
@@ -127,21 +205,23 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
127
205
|
// Response Scroll Handler (j/k)
|
|
128
206
|
useKeyPress({
|
|
129
207
|
j: () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
208
|
+
setResScroll((prev) => {
|
|
209
|
+
const maxScroll = Math.max(0, resTotalLines - contentHeight);
|
|
210
|
+
if (prev < maxScroll) {
|
|
211
|
+
return Math.min(prev + 1, maxScroll);
|
|
212
|
+
}
|
|
213
|
+
return prev;
|
|
214
|
+
});
|
|
134
215
|
},
|
|
135
216
|
k: () => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
217
|
+
setResScroll((prev) => {
|
|
218
|
+
if (prev > 0)
|
|
219
|
+
return prev - 1;
|
|
220
|
+
dispatch(detailSetSubFocus('TABS'));
|
|
221
|
+
return 0;
|
|
222
|
+
});
|
|
143
223
|
},
|
|
144
|
-
}, mode === 'NORMAL' && activePanel === 'response');
|
|
224
|
+
}, mode === 'NORMAL' && activePanel === 'response' && subFocus === 'CONTENT');
|
|
145
225
|
// 렌더링 헬퍼
|
|
146
226
|
const renderLines = (lines, offset, limit) => {
|
|
147
227
|
const visible = lines.slice(offset, offset + limit);
|
|
@@ -166,9 +246,22 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
166
246
|
return renderKeyValue(requestHeaders, reqScroll, contentHeight);
|
|
167
247
|
}
|
|
168
248
|
if (activeRequestTab === 'body') {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
249
|
+
let content = null;
|
|
250
|
+
if (!requestBody) {
|
|
251
|
+
content = React.createElement(Text, { color: "gray" }, "No body");
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
const lines = requestBody.split('\n');
|
|
255
|
+
content = renderLines(lines, reqScroll, contentHeight);
|
|
256
|
+
}
|
|
257
|
+
// Body 탭이고 Request 패널 활성 상태면 항상 안내 문구 표시
|
|
258
|
+
if (activePanel === 'request') {
|
|
259
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
260
|
+
content,
|
|
261
|
+
React.createElement(Box, { borderStyle: "single", borderColor: "yellow", marginTop: 0 },
|
|
262
|
+
React.createElement(Text, { color: "yellow" }, "Press 'e' key to open external editor."))));
|
|
263
|
+
}
|
|
264
|
+
return content;
|
|
172
265
|
}
|
|
173
266
|
if (activeRequestTab === 'query') {
|
|
174
267
|
const queries = endpoint.parameters.filter((p) => p.in === 'query');
|
|
@@ -226,17 +319,23 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
226
319
|
];
|
|
227
320
|
return (React.createElement(Box, { flexDirection: "column", width: "50%", marginLeft: 1 },
|
|
228
321
|
React.createElement(Box, { flexDirection: "column", height: requestBoxHeight, borderStyle: "single", borderColor: activePanel === 'request' ? 'green' : 'gray', paddingX: 1 },
|
|
229
|
-
React.createElement(Box, { justifyContent: "space-between", height: 1 },
|
|
230
|
-
React.createElement(
|
|
322
|
+
React.createElement(Box, { justifyContent: "space-between", height: 1, marginBottom: 0 },
|
|
323
|
+
React.createElement(Box, null,
|
|
324
|
+
React.createElement(Text, { color: activePanel === 'request' && subFocus === 'TITLE' ? 'yellow' : 'white', bold: activePanel === 'request' && subFocus === 'TITLE' },
|
|
325
|
+
activePanel === 'request' && subFocus === 'TITLE' ? '>' : ' ',
|
|
326
|
+
"Request")),
|
|
231
327
|
React.createElement(Box, { width: 15 },
|
|
232
328
|
React.createElement(Text, { color: "gray" }, reqTotalLines > contentHeight
|
|
233
329
|
? `${reqScroll + 1}-${Math.min(reqScroll + contentHeight, reqTotalLines)}/${reqTotalLines}`
|
|
234
330
|
: ' '))),
|
|
235
|
-
React.createElement(TabHeader, { tabs: reqTabs, active: activeRequestTab, isFocused: activePanel === 'request', shortcutPrefix: "rq", commandMode: commandMode }),
|
|
236
|
-
React.createElement(Box, { flexGrow: 1, flexDirection: "column", overflow: "hidden" }, renderRequestContent())),
|
|
331
|
+
React.createElement(TabHeader, { tabs: reqTabs, active: activeRequestTab, isFocused: activePanel === 'request' && subFocus === 'TABS', shortcutPrefix: "rq", commandMode: commandMode }),
|
|
332
|
+
React.createElement(Box, { flexGrow: 1, flexDirection: "column", overflow: "hidden", borderStyle: "single", borderColor: activePanel === 'request' && subFocus === 'CONTENT' ? 'green' : 'gray', paddingX: 1 }, renderRequestContent())),
|
|
237
333
|
React.createElement(Box, { flexDirection: "column", height: responseBoxHeight, borderStyle: "single", borderColor: activePanel === 'response' ? 'green' : 'gray', paddingX: 1 },
|
|
238
|
-
React.createElement(Box, { justifyContent: "space-between", height: 1 },
|
|
239
|
-
React.createElement(
|
|
334
|
+
React.createElement(Box, { justifyContent: "space-between", height: 1, marginBottom: 0 },
|
|
335
|
+
React.createElement(Box, null,
|
|
336
|
+
React.createElement(Text, { color: activePanel === 'response' && subFocus === 'TITLE' ? 'yellow' : 'white', bold: activePanel === 'response' && subFocus === 'TITLE' },
|
|
337
|
+
activePanel === 'response' && subFocus === 'TITLE' ? '>' : ' ',
|
|
338
|
+
"Response")),
|
|
240
339
|
React.createElement(Box, { width: 30, flexDirection: "row", justifyContent: "flex-end" },
|
|
241
340
|
response ? (React.createElement(Text, { color: response.status < 300 ? 'green' : 'red' },
|
|
242
341
|
response.status,
|
|
@@ -246,6 +345,6 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
246
345
|
React.createElement(Text, { color: "gray" }, resTotalLines > contentHeight
|
|
247
346
|
? `${resScroll + 1}-${Math.min(resScroll + contentHeight, resTotalLines)}/${resTotalLines}`
|
|
248
347
|
: ' ')))),
|
|
249
|
-
React.createElement(TabHeader, { tabs: resTabs, active: activeResponseTab, isFocused: activePanel === 'response', shortcutPrefix: "rs", commandMode: commandMode }),
|
|
250
|
-
React.createElement(Box, { flexGrow: 1, flexDirection: "column", overflow: "hidden" }, renderResponseContent()))));
|
|
348
|
+
React.createElement(TabHeader, { tabs: resTabs, active: activeResponseTab, isFocused: activePanel === 'response' && subFocus === 'TABS', shortcutPrefix: "rs", commandMode: commandMode }),
|
|
349
|
+
React.createElement(Box, { flexGrow: 1, flexDirection: "column", overflow: "hidden", borderStyle: "single", borderColor: activePanel === 'response' && subFocus === 'CONTENT' ? 'green' : 'gray', paddingX: 1 }, renderResponseContent()))));
|
|
251
350
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 엔드포인트 목록 컴포넌트
|
|
3
3
|
*/
|
|
4
|
-
import React
|
|
4
|
+
import React from 'react';
|
|
5
5
|
import { Box, Text, useStdout } from 'ink';
|
|
6
|
+
import { useAppDispatch, useAppSelector } from '../../store/hooks.js';
|
|
7
|
+
import { listScroll } from '../../store/appSlice.js';
|
|
6
8
|
export default function EndpointList({ endpoints, selectedIndex, commandMode = false }) {
|
|
7
9
|
const { stdout } = useStdout();
|
|
10
|
+
const dispatch = useAppDispatch();
|
|
11
|
+
const scrollOffset = useAppSelector((state) => state.app.list.scrollOffset);
|
|
8
12
|
// 가용 높이 계산: 터미널 높이 - 헤더(2) - 푸터(2) - 메인박스보더(2) - 메인박스패딩(1) - 필터바(1) - 검색바(1) = 9
|
|
9
13
|
// 안전하게 여유를 두어 계산 (-9)
|
|
10
14
|
const availableHeight = (stdout?.rows || 24) - 9;
|
|
@@ -21,18 +25,19 @@ export default function EndpointList({ endpoints, selectedIndex, commandMode = f
|
|
|
21
25
|
// Path가 중요하므로 더 많은 공간 배정
|
|
22
26
|
const pathWidth = Math.floor(availableContentWidth * 0.7);
|
|
23
27
|
const descriptionWidth = Math.max(0, availableContentWidth - pathWidth);
|
|
24
|
-
const [scrollOffset, setScrollOffset] = useState(0);
|
|
25
28
|
// 렌더링 중에 스크롤 오프셋 조정 (Derived State Pattern)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
if (selectedIndex >= 0) {
|
|
31
|
+
const visibleStart = scrollOffset;
|
|
32
|
+
const visibleEnd = scrollOffset + availableHeight - 1;
|
|
33
|
+
if (selectedIndex < visibleStart) {
|
|
34
|
+
dispatch(listScroll(selectedIndex));
|
|
35
|
+
}
|
|
36
|
+
else if (selectedIndex > visibleEnd) {
|
|
37
|
+
dispatch(listScroll(selectedIndex - availableHeight + 1));
|
|
38
|
+
}
|
|
31
39
|
}
|
|
32
|
-
|
|
33
|
-
setScrollOffset(selectedIndex - availableHeight + 1);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
40
|
+
}, [selectedIndex, scrollOffset, availableHeight, dispatch]);
|
|
36
41
|
const getMethodColor = (method) => {
|
|
37
42
|
const colors = {
|
|
38
43
|
GET: 'blue',
|
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
* API 목록 화면
|
|
3
3
|
*/
|
|
4
4
|
import React, { useMemo } from 'react';
|
|
5
|
-
import { Box, useStdout } from 'ink';
|
|
5
|
+
import { Box, Text, useStdout } from 'ink';
|
|
6
6
|
import { useAppSelector, useAppDispatch } from '../../store/hooks.js';
|
|
7
|
-
import { setMode, listSelect,
|
|
7
|
+
import { setMode, listSelect, listSetMethodFilter, listSetTag, listSetFocus, listSelectTag, listSelectMethod, listSetSearch, listSetTagSearch, } from '../../store/appSlice.js';
|
|
8
|
+
import { navigateUp, navigateDown, navigateLeft, navigateRight, } from '../../store/navigationActions.js';
|
|
8
9
|
import { useNavigation } from '../../hooks/useNavigation.js';
|
|
9
10
|
import { useKeyPress } from '../../hooks/useKeyPress.js';
|
|
10
11
|
import { OpenAPIParserService } from '../../services/openapi-parser.js';
|
|
11
12
|
import StatusBar from '../common/StatusBar.js';
|
|
12
13
|
import KeybindingFooter from '../common/KeybindingFooter.js';
|
|
13
14
|
import EndpointList from './EndpointList.js';
|
|
14
|
-
import
|
|
15
|
+
import MethodsBar from './MethodsBar.js';
|
|
15
16
|
import SearchBar from './SearchBar.js';
|
|
16
17
|
import Explorer from './Explorer.js';
|
|
17
18
|
import { setCommandInput } from '../../store/appSlice.js';
|
|
@@ -43,15 +44,15 @@ export default function ListView() {
|
|
|
43
44
|
filtered = filtered.filter((ep) => ep.tags?.includes(list.activeTag));
|
|
44
45
|
}
|
|
45
46
|
// 메서드 필터
|
|
46
|
-
if (list.
|
|
47
|
-
filtered = parser.
|
|
47
|
+
if (list.activeMethod !== 'ALL') {
|
|
48
|
+
filtered = parser.filterByMethod(filtered, list.activeMethod);
|
|
48
49
|
}
|
|
49
50
|
// 검색
|
|
50
51
|
if (list.searchQuery) {
|
|
51
52
|
filtered = parser.searchEndpoints(filtered, list.searchQuery);
|
|
52
53
|
}
|
|
53
54
|
return filtered;
|
|
54
|
-
}, [list.
|
|
55
|
+
}, [list.activeMethod, list.searchQuery, list.activeTag, endpoints, parser]);
|
|
55
56
|
// 커맨드 실행 핸들러
|
|
56
57
|
const handleCommandSubmit = (cmd) => {
|
|
57
58
|
// 1. 종료 명령어 처리
|
|
@@ -72,11 +73,12 @@ export default function ListView() {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
else if (type === 'm') {
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
if (targetIndex >= 0 && targetIndex <
|
|
78
|
-
dispatch(
|
|
79
|
-
dispatch(
|
|
76
|
+
// Method selection
|
|
77
|
+
const methods = ['ALL', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
78
|
+
if (targetIndex >= 0 && targetIndex < methods.length) {
|
|
79
|
+
dispatch(listSetMethodFilter(methods[targetIndex]));
|
|
80
|
+
dispatch(listSelectMethod(targetIndex));
|
|
81
|
+
dispatch(listSetFocus('METHODS'));
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
else if (type === 'e') {
|
|
@@ -99,11 +101,10 @@ export default function ListView() {
|
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
else {
|
|
102
|
-
// LIST focus:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
dispatch(listSetFilter(filters[targetIndex]));
|
|
104
|
+
// LIST focus: Method selection
|
|
105
|
+
const methods = ['ALL', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
106
|
+
if (targetIndex >= 0 && targetIndex < methods.length) {
|
|
107
|
+
dispatch(listSetMethodFilter(methods[targetIndex]));
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
dispatch(setMode('NORMAL'));
|
|
@@ -115,30 +116,16 @@ export default function ListView() {
|
|
|
115
116
|
// 키보드 입력 처리
|
|
116
117
|
useKeyPress({
|
|
117
118
|
onTab: () => {
|
|
118
|
-
const
|
|
119
|
-
|
|
119
|
+
const focusCycle = ['TAGS', 'METHODS', 'LIST'];
|
|
120
|
+
const currentIndex = focusCycle.indexOf(list.focus);
|
|
121
|
+
const nextIndex = (currentIndex + 1) % focusCycle.length;
|
|
122
|
+
dispatch(listSetFocus(focusCycle[nextIndex]));
|
|
120
123
|
},
|
|
121
124
|
j: () => {
|
|
122
|
-
|
|
123
|
-
const nextIndex = Math.min(list.selectedTagIndex + 1, filteredTags.length - 1);
|
|
124
|
-
dispatch(listSelectTag(nextIndex));
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
// 다음 항목
|
|
128
|
-
const nextIndex = Math.min(list.selectedIndex + 1, filteredEndpoints.length - 1);
|
|
129
|
-
dispatch(listSelect(nextIndex));
|
|
130
|
-
}
|
|
125
|
+
dispatch(navigateDown());
|
|
131
126
|
},
|
|
132
127
|
k: () => {
|
|
133
|
-
|
|
134
|
-
const prevIndex = Math.max(list.selectedTagIndex - 1, 0);
|
|
135
|
-
dispatch(listSelectTag(prevIndex));
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
// 이전 항목
|
|
139
|
-
const prevIndex = Math.max(list.selectedIndex - 1, 0);
|
|
140
|
-
dispatch(listSelect(prevIndex));
|
|
141
|
-
}
|
|
128
|
+
dispatch(navigateUp());
|
|
142
129
|
},
|
|
143
130
|
// Page Up ({)
|
|
144
131
|
'{': () => {
|
|
@@ -192,38 +179,27 @@ export default function ListView() {
|
|
|
192
179
|
if (actualTag !== list.activeTag) {
|
|
193
180
|
dispatch(listSetTag(actualTag));
|
|
194
181
|
}
|
|
195
|
-
// 포커스 유지 (Enter는 선택만 수행)
|
|
196
182
|
}
|
|
197
|
-
else {
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
if (
|
|
201
|
-
|
|
183
|
+
else if (list.focus === 'METHODS') {
|
|
184
|
+
const methods = ['ALL', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
185
|
+
const selectedMethod = methods[list.focusedMethodIndex];
|
|
186
|
+
if (selectedMethod !== list.activeMethod) {
|
|
187
|
+
dispatch(listSetMethodFilter(selectedMethod));
|
|
202
188
|
}
|
|
203
189
|
}
|
|
204
|
-
},
|
|
205
|
-
l: () => {
|
|
206
|
-
if (list.focus === 'TAGS') {
|
|
207
|
-
const tag = filteredTags[list.selectedTagIndex];
|
|
208
|
-
const actualTag = tag === 'Overview' ? null : tag;
|
|
209
|
-
// 다른 태그를 선택한 경우에만 태그 변경 (selectedIndex 리셋됨)
|
|
210
|
-
if (actualTag !== list.activeTag) {
|
|
211
|
-
dispatch(listSetTag(actualTag));
|
|
212
|
-
}
|
|
213
|
-
// l 키는 오른쪽 패널로 이동
|
|
214
|
-
dispatch(listSetFocus('LIST'));
|
|
215
|
-
}
|
|
216
190
|
else {
|
|
191
|
+
// 상세 페이지 이동은 엔터로만 작동
|
|
217
192
|
const selected = filteredEndpoints[list.selectedIndex];
|
|
218
193
|
if (selected) {
|
|
219
194
|
goToDetail(selected);
|
|
220
195
|
}
|
|
221
196
|
}
|
|
222
197
|
},
|
|
198
|
+
l: () => {
|
|
199
|
+
dispatch(navigateRight());
|
|
200
|
+
},
|
|
223
201
|
h: () => {
|
|
224
|
-
|
|
225
|
-
dispatch(listSetFocus('TAGS'));
|
|
226
|
-
}
|
|
202
|
+
dispatch(navigateLeft());
|
|
227
203
|
},
|
|
228
204
|
}, mode === 'NORMAL');
|
|
229
205
|
// Command 모드 처리
|
|
@@ -290,9 +266,15 @@ export default function ListView() {
|
|
|
290
266
|
React.createElement(Box, { flexDirection: "row", flexGrow: 1, overflow: "hidden" },
|
|
291
267
|
React.createElement(Box, null,
|
|
292
268
|
React.createElement(Explorer, { tags: tags, selectedTagIndex: list.selectedTagIndex, activeTag: list.activeTag, isFocused: list.focus === 'TAGS', searchQuery: list.tagSearchQuery, isSearchMode: mode === 'TAG_SEARCH', commandMode: mode === 'COMMAND', onSearchChange: (query) => dispatch(listSetTagSearch(query)) })),
|
|
293
|
-
React.createElement(Box, { flexDirection: "column", flexGrow: 1, marginLeft: 1, borderStyle: "single", borderColor: list.focus === 'LIST'
|
|
269
|
+
React.createElement(Box, { flexDirection: "column", flexGrow: 1, marginLeft: 1, borderStyle: "single", borderColor: list.focus === 'LIST' || list.focus === 'METHODS' || list.focus === 'TITLE'
|
|
270
|
+
? 'green'
|
|
271
|
+
: 'gray', paddingX: 1, paddingBottom: 1, paddingTop: 0 },
|
|
272
|
+
React.createElement(Box, { marginBottom: 0 },
|
|
273
|
+
React.createElement(Text, { color: list.focus === 'TITLE' ? 'yellow' : 'white', bold: list.focus === 'TITLE' },
|
|
274
|
+
list.focus === 'TITLE' ? '>' : ' ',
|
|
275
|
+
"Endpoints")),
|
|
294
276
|
React.createElement(Box, { flexDirection: "row", alignItems: "center", gap: 2 },
|
|
295
|
-
React.createElement(
|
|
277
|
+
React.createElement(MethodsBar, { activeMethod: list.activeMethod, commandMode: mode === 'COMMAND', isFocused: list.focus === 'METHODS', focusedIndex: list.focusedMethodIndex, onMethodChange: (method) => dispatch(listSetMethodFilter(method)) })),
|
|
296
278
|
React.createElement(SearchBar, { query: list.searchQuery, onQueryChange: (query) => dispatch(listSetSearch(query)), isSearchMode: mode === 'SEARCH' }),
|
|
297
279
|
React.createElement(Box, { flexGrow: 1, marginTop: 0, overflow: "hidden" },
|
|
298
280
|
React.createElement(EndpointList, { endpoints: filteredEndpoints, selectedIndex: list.selectedIndex, commandMode: mode === 'COMMAND' })))),
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 메서드 선택 바 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { HttpMethod } from '../../types/openapi.js';
|
|
6
|
+
interface MethodsBarProps {
|
|
7
|
+
activeMethod: HttpMethod | 'ALL';
|
|
8
|
+
commandMode?: boolean;
|
|
9
|
+
onMethodChange: (method: HttpMethod | 'ALL') => void;
|
|
10
|
+
isFocused?: boolean;
|
|
11
|
+
focusedIndex?: number;
|
|
12
|
+
}
|
|
13
|
+
export default function MethodsBar({ activeMethod, commandMode, onMethodChange: _onMethodChange, isFocused, focusedIndex }: MethodsBarProps): React.JSX.Element;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 메서드 선택 바 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
import { HTTP_METHODS } from '../../types/openapi.js';
|
|
7
|
+
export default function MethodsBar({ activeMethod, commandMode = false, onMethodChange: _onMethodChange, isFocused = false, focusedIndex = 0 }) {
|
|
8
|
+
const methods = ['ALL', ...HTTP_METHODS];
|
|
9
|
+
const getMethodColor = (method) => {
|
|
10
|
+
if (method === 'ALL')
|
|
11
|
+
return 'green';
|
|
12
|
+
const colors = {
|
|
13
|
+
GET: 'blue',
|
|
14
|
+
POST: 'green',
|
|
15
|
+
PUT: 'yellow',
|
|
16
|
+
DELETE: 'red',
|
|
17
|
+
PATCH: 'magenta',
|
|
18
|
+
OPTIONS: 'cyan',
|
|
19
|
+
HEAD: 'gray',
|
|
20
|
+
TRACE: 'white',
|
|
21
|
+
};
|
|
22
|
+
return colors[method] || 'white';
|
|
23
|
+
};
|
|
24
|
+
return (React.createElement(Box, { paddingX: 1, paddingY: 0, gap: 1, height: 1 }, methods.slice(0, 6).map((method, index) => {
|
|
25
|
+
const isActive = method === activeMethod;
|
|
26
|
+
const isActuallyFocused = isFocused && index === focusedIndex;
|
|
27
|
+
const color = getMethodColor(method);
|
|
28
|
+
return (React.createElement(Box, { key: method, gap: 0 },
|
|
29
|
+
commandMode && React.createElement(Text, { color: "yellow" },
|
|
30
|
+
"m",
|
|
31
|
+
index + 1),
|
|
32
|
+
React.createElement(Text, { color: "yellow" }, isActuallyFocused ? '>' : ' '),
|
|
33
|
+
React.createElement(Text, { backgroundColor: isActive ? color : undefined, color: isActive ? 'black' : isActuallyFocused ? 'yellow' : 'gray', bold: isActive || isActuallyFocused, underline: isActuallyFocused }, method),
|
|
34
|
+
React.createElement(Text, null, " ")));
|
|
35
|
+
})));
|
|
36
|
+
}
|