@gomjellie/lazyapi 0.0.2 → 0.0.3
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/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 +3 -2
- package/dist/components/detail-view/RightPanel.js +119 -31
- package/dist/components/list-view/EndpointList.js +16 -11
- package/dist/components/list-view/ListView.js +34 -58
- 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 +2 -2
- package/dist/store/appSlice.js +43 -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 +129 -0
- package/dist/types/app-state.d.ts +13 -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, 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;
|
|
@@ -26,8 +31,9 @@ const TabHeader = ({ tabs, active, isFocused, shortcutPrefix, commandMode, }) =>
|
|
|
26
31
|
showHint && React.createElement(Text, { color: "yellow" }, hintText),
|
|
27
32
|
React.createElement(Text, { bold: t.id === active, color: t.id === active ? 'black' : 'gray', backgroundColor: t.id === active ? 'green' : undefined }, ' ' + t.label + ' ')));
|
|
28
33
|
})));
|
|
29
|
-
export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, response, endpoint, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode = false, }) {
|
|
34
|
+
export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, response, endpoint, document, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode = false, }) {
|
|
30
35
|
const { stdout } = useStdout();
|
|
36
|
+
const { isRawModeSupported, setRawMode } = useStdin();
|
|
31
37
|
const dispatch = useAppDispatch();
|
|
32
38
|
// 높이 계산
|
|
33
39
|
const totalHeight = (stdout?.rows || 24) - 4;
|
|
@@ -37,9 +43,28 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
37
43
|
// -- Request Box State --
|
|
38
44
|
const [reqScroll, setReqScroll] = useState(0);
|
|
39
45
|
useEffect(() => {
|
|
40
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
41
46
|
setReqScroll(0);
|
|
42
47
|
}, [activeRequestTab, endpoint]);
|
|
48
|
+
// Request Body 자동 스캐폴딩
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (activeRequestTab === 'body' && activePanel === 'request' && !requestBody) {
|
|
51
|
+
// 1. requestBody 필드 (OpenAPI 3.0)
|
|
52
|
+
if (endpoint.requestBody?.schema) {
|
|
53
|
+
const scaffolded = scaffoldSchema(endpoint.requestBody.schema, document);
|
|
54
|
+
const value = JSON.stringify(scaffolded, null, 2);
|
|
55
|
+
dispatch(detailSetBody(value));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// 2. in: body 파라미터 (Swagger 2.0)
|
|
59
|
+
const bodyParam = endpoint.parameters.find(p => p.in === 'body');
|
|
60
|
+
if (bodyParam && bodyParam.schema) {
|
|
61
|
+
const scaffolded = scaffoldSchema(bodyParam.schema, document);
|
|
62
|
+
const value = JSON.stringify(scaffolded, null, 2);
|
|
63
|
+
dispatch(detailSetBody(value));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}, [activeRequestTab, activePanel, requestBody, endpoint, document, dispatch]);
|
|
43
68
|
// Request Content Lines 계산
|
|
44
69
|
const getRequestLines = () => {
|
|
45
70
|
if (activeRequestTab === 'headers') {
|
|
@@ -59,41 +84,85 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
59
84
|
// Request Scroll Handler (j/k)
|
|
60
85
|
useKeyPress({
|
|
61
86
|
j: () => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
87
|
+
setReqScroll((prev) => {
|
|
88
|
+
const maxScroll = Math.max(0, reqTotalLines - contentHeight);
|
|
89
|
+
if (prev < maxScroll) {
|
|
90
|
+
return Math.min(prev + 1, maxScroll);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// 스크롤이 끝에 도달하면 Response 패널로 이동
|
|
94
|
+
dispatch(detailSetPanel('response'));
|
|
95
|
+
return prev;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
70
98
|
},
|
|
71
99
|
k: () => {
|
|
72
100
|
setReqScroll((prev) => Math.max(prev - 1, 0));
|
|
73
101
|
},
|
|
74
102
|
}, mode === 'NORMAL' && activePanel === 'request');
|
|
103
|
+
const openExternalEditor = () => {
|
|
104
|
+
try {
|
|
105
|
+
const tempFile = path.join(os.tmpdir(), `lazyapi-body-${Date.now()}.json`);
|
|
106
|
+
fs.writeFileSync(tempFile, requestBody || '');
|
|
107
|
+
const editor = process.env.EDITOR || 'vim';
|
|
108
|
+
// Ink의 Raw 모드를 해제하고 stdin을 일시 중지하여 외부 에디터에 제어권 이양
|
|
109
|
+
if (isRawModeSupported) {
|
|
110
|
+
setRawMode(false);
|
|
111
|
+
}
|
|
112
|
+
process.stdin.pause();
|
|
113
|
+
try {
|
|
114
|
+
// 동기적으로 실행하여 에디터 종료까지 대기
|
|
115
|
+
spawnSync(editor, [tempFile], {
|
|
116
|
+
stdio: 'inherit',
|
|
117
|
+
});
|
|
118
|
+
if (fs.existsSync(tempFile)) {
|
|
119
|
+
const content = fs.readFileSync(tempFile, 'utf-8');
|
|
120
|
+
dispatch(detailSetBody(content));
|
|
121
|
+
fs.unlinkSync(tempFile);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (innerError) {
|
|
125
|
+
console.error('Error executing editor:', innerError);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
// 에디터 종료 후 stdin 재개 및 Raw 모드 복구
|
|
129
|
+
process.stdin.resume();
|
|
130
|
+
if (isRawModeSupported) {
|
|
131
|
+
setRawMode(true);
|
|
132
|
+
}
|
|
133
|
+
dispatch(setMode('NORMAL'));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
console.error('Failed to open editor', e);
|
|
138
|
+
dispatch(setMode('NORMAL'));
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// e 키: Request Body 탭이 활성화되어 있으면 항상 에디터 오픈 (NORMAL/INSERT 모두)
|
|
142
|
+
useKeyPress({
|
|
143
|
+
e: openExternalEditor,
|
|
144
|
+
}, activePanel === 'request' && activeRequestTab === 'body');
|
|
75
145
|
// -- Response Box State --
|
|
76
146
|
const [resScroll, setResScroll] = useState(0);
|
|
77
147
|
useEffect(() => {
|
|
78
148
|
// 탭이 변경될 때만 스크롤 리셋 (response 변경 시에는 리셋하지 않음)
|
|
79
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
80
149
|
setResScroll(0);
|
|
81
150
|
}, [activeResponseTab]);
|
|
82
151
|
// Curl Command 생성 (메모이제이션)
|
|
83
152
|
const curlCommand = useMemo(() => {
|
|
84
|
-
let
|
|
153
|
+
let pathStr = endpoint.path;
|
|
85
154
|
endpoint.parameters
|
|
86
155
|
.filter((p) => p.in === 'path')
|
|
87
156
|
.forEach((param) => {
|
|
88
157
|
const value = parameterValues[param.name] || '';
|
|
89
|
-
|
|
158
|
+
pathStr = pathStr.replace(`{${param.name}}`, value);
|
|
90
159
|
});
|
|
91
160
|
const queryParams = endpoint.parameters
|
|
92
161
|
.filter((p) => p.in === 'query')
|
|
93
162
|
.filter((param) => parameterValues[param.name])
|
|
94
163
|
.map((param) => `${param.name}=${encodeURIComponent(parameterValues[param.name])}`)
|
|
95
164
|
.join('&');
|
|
96
|
-
const url = queryParams ? `${baseURL}${
|
|
165
|
+
const url = queryParams ? `${baseURL}${pathStr}?${queryParams}` : `${baseURL}${pathStr}`;
|
|
97
166
|
let headerStr = '';
|
|
98
167
|
Object.entries(requestHeaders).forEach(([key, val]) => {
|
|
99
168
|
headerStr += ` -H '${key}: ${val}'`;
|
|
@@ -127,19 +196,25 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
127
196
|
// Response Scroll Handler (j/k)
|
|
128
197
|
useKeyPress({
|
|
129
198
|
j: () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
199
|
+
setResScroll((prev) => {
|
|
200
|
+
const maxScroll = Math.max(0, resTotalLines - contentHeight);
|
|
201
|
+
if (prev < maxScroll) {
|
|
202
|
+
return Math.min(prev + 1, maxScroll);
|
|
203
|
+
}
|
|
204
|
+
return prev;
|
|
205
|
+
});
|
|
134
206
|
},
|
|
135
207
|
k: () => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
208
|
+
setResScroll((prev) => {
|
|
209
|
+
if (prev > 0) {
|
|
210
|
+
return Math.max(prev - 1, 0);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// 스크롤이 맨 위에 도달하면 Request 패널로 이동
|
|
214
|
+
dispatch(detailSetPanel('request'));
|
|
215
|
+
return prev;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
143
218
|
},
|
|
144
219
|
}, mode === 'NORMAL' && activePanel === 'response');
|
|
145
220
|
// 렌더링 헬퍼
|
|
@@ -166,9 +241,22 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
166
241
|
return renderKeyValue(requestHeaders, reqScroll, contentHeight);
|
|
167
242
|
}
|
|
168
243
|
if (activeRequestTab === 'body') {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
244
|
+
let content = null;
|
|
245
|
+
if (!requestBody) {
|
|
246
|
+
content = React.createElement(Text, { color: "gray" }, "No body");
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
const lines = requestBody.split('\n');
|
|
250
|
+
content = renderLines(lines, reqScroll, contentHeight);
|
|
251
|
+
}
|
|
252
|
+
// Body 탭이고 Request 패널 활성 상태면 항상 안내 문구 표시
|
|
253
|
+
if (activePanel === 'request') {
|
|
254
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
255
|
+
content,
|
|
256
|
+
React.createElement(Box, { borderStyle: "single", borderColor: "yellow", marginTop: 0 },
|
|
257
|
+
React.createElement(Text, { color: "yellow" }, "Press 'e' key to open external editor."))));
|
|
258
|
+
}
|
|
259
|
+
return content;
|
|
172
260
|
}
|
|
173
261
|
if (activeRequestTab === 'query') {
|
|
174
262
|
const queries = endpoint.parameters.filter((p) => p.in === 'query');
|
|
@@ -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',
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
import React, { useMemo } from 'react';
|
|
5
5
|
import { Box, 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,9 @@ 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' ? 'green' : 'gray', paddingX: 1, paddingBottom: 1, paddingTop: 0 },
|
|
269
|
+
React.createElement(Box, { flexDirection: "column", flexGrow: 1, marginLeft: 1, borderStyle: "single", borderColor: list.focus === 'LIST' || list.focus === 'METHODS' ? 'green' : 'gray', paddingX: 1, paddingBottom: 1, paddingTop: 0 },
|
|
294
270
|
React.createElement(Box, { flexDirection: "row", alignItems: "center", gap: 2 },
|
|
295
|
-
React.createElement(
|
|
271
|
+
React.createElement(MethodsBar, { activeMethod: list.activeMethod, commandMode: mode === 'COMMAND', isFocused: list.focus === 'METHODS', focusedIndex: list.focusedMethodIndex, onMethodChange: (method) => dispatch(listSetMethodFilter(method)) })),
|
|
296
272
|
React.createElement(SearchBar, { query: list.searchQuery, onQueryChange: (query) => dispatch(listSetSearch(query)), isSearchMode: mode === 'SEARCH' }),
|
|
297
273
|
React.createElement(Box, { flexGrow: 1, marginTop: 0, overflow: "hidden" },
|
|
298
274
|
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
|
+
}
|
|
@@ -2,56 +2,99 @@
|
|
|
2
2
|
* 키보드 입력 처리 Hook
|
|
3
3
|
*/
|
|
4
4
|
import { useInput } from 'ink';
|
|
5
|
-
import { useCallback } from 'react';
|
|
5
|
+
import { useCallback, useRef, useEffect } from 'react';
|
|
6
|
+
import { useAppDispatch, useAppSelector } from '../store/hooks.js';
|
|
7
|
+
import { setNavigationCount } from '../store/appSlice.js';
|
|
6
8
|
/**
|
|
7
9
|
* 키보드 입력을 처리하는 Hook
|
|
8
10
|
*/
|
|
9
11
|
export function useKeyPress(bindings, isEnabled = true) {
|
|
12
|
+
const dispatch = useAppDispatch();
|
|
13
|
+
const navigationCount = useAppSelector((state) => state.app.navigationCount);
|
|
14
|
+
const mode = useAppSelector((state) => state.app.mode);
|
|
15
|
+
// useInput 핸들러가 최신 상태를 참조할 수 있도록 useRef 사용
|
|
16
|
+
const stateRef = useRef({ navigationCount, mode, bindings, isEnabled });
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
stateRef.current = { navigationCount, mode, bindings, isEnabled };
|
|
19
|
+
}, [navigationCount, mode, bindings, isEnabled]);
|
|
10
20
|
const handleInput = useCallback((input, key) => {
|
|
11
|
-
|
|
21
|
+
const { navigationCount: currentCount, mode: currentMode, bindings: currentBindings, isEnabled: currentEnabled } = stateRef.current;
|
|
22
|
+
if (!currentEnabled)
|
|
12
23
|
return;
|
|
24
|
+
// 숫자 접두사 처리 (NORMAL 모드에서만)
|
|
25
|
+
if (currentMode === 'NORMAL' && input && /^[0-9]$/.test(input)) {
|
|
26
|
+
// 첫 숫자가 0인 경우는 접두사로 쓰이지 않음 (Vim 스타일)
|
|
27
|
+
if (input === '0' && currentCount === null) {
|
|
28
|
+
// 0은 바인딩된 기능이 있으면 실행 (보통 줄 맨 앞으로 이동)
|
|
29
|
+
if (currentBindings['0']) {
|
|
30
|
+
currentBindings['0'](input, key);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const digit = parseInt(input, 10);
|
|
35
|
+
if (!isNaN(digit)) {
|
|
36
|
+
const newCount = currentCount === null ? digit : currentCount * 10 + digit;
|
|
37
|
+
dispatch(setNavigationCount(newCount));
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const executeBinding = (handler, inputStr, keyObj) => {
|
|
42
|
+
if (!handler)
|
|
43
|
+
return;
|
|
44
|
+
const count = currentCount || 1;
|
|
45
|
+
for (let i = 0; i < count; i++) {
|
|
46
|
+
handler(inputStr, keyObj);
|
|
47
|
+
}
|
|
48
|
+
if (currentCount !== null) {
|
|
49
|
+
dispatch(setNavigationCount(null));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
13
52
|
// 특수 키 처리
|
|
14
53
|
if (key.tab) {
|
|
15
|
-
if (key.shift &&
|
|
16
|
-
|
|
54
|
+
if (key.shift && currentBindings.onShiftTab) {
|
|
55
|
+
executeBinding(currentBindings.onShiftTab, input, key);
|
|
17
56
|
}
|
|
18
|
-
else if (
|
|
19
|
-
|
|
57
|
+
else if (currentBindings.onTab) {
|
|
58
|
+
executeBinding(currentBindings.onTab, input, key);
|
|
20
59
|
}
|
|
21
60
|
return;
|
|
22
61
|
}
|
|
23
|
-
if (key.escape &&
|
|
24
|
-
|
|
62
|
+
if (key.escape && currentBindings.onEscape) {
|
|
63
|
+
executeBinding(currentBindings.onEscape, input, key);
|
|
25
64
|
return;
|
|
26
65
|
}
|
|
27
|
-
if (key.return &&
|
|
28
|
-
|
|
66
|
+
if (key.return && currentBindings.onReturn) {
|
|
67
|
+
executeBinding(currentBindings.onReturn, input, key);
|
|
29
68
|
return;
|
|
30
69
|
}
|
|
31
|
-
if (key.backspace &&
|
|
32
|
-
|
|
70
|
+
if (key.backspace && currentBindings.onBackspace) {
|
|
71
|
+
executeBinding(currentBindings.onBackspace, input, key);
|
|
33
72
|
return;
|
|
34
73
|
}
|
|
35
|
-
if (key.upArrow &&
|
|
36
|
-
|
|
74
|
+
if (key.upArrow && currentBindings.onUpArrow) {
|
|
75
|
+
executeBinding(currentBindings.onUpArrow, input, key);
|
|
37
76
|
return;
|
|
38
77
|
}
|
|
39
|
-
if (key.downArrow &&
|
|
40
|
-
|
|
78
|
+
if (key.downArrow && currentBindings.onDownArrow) {
|
|
79
|
+
executeBinding(currentBindings.onDownArrow, input, key);
|
|
41
80
|
return;
|
|
42
81
|
}
|
|
43
|
-
if (key.leftArrow &&
|
|
44
|
-
|
|
82
|
+
if (key.leftArrow && currentBindings.onLeftArrow) {
|
|
83
|
+
executeBinding(currentBindings.onLeftArrow, input, key);
|
|
45
84
|
return;
|
|
46
85
|
}
|
|
47
|
-
if (key.rightArrow &&
|
|
48
|
-
|
|
86
|
+
if (key.rightArrow && currentBindings.onRightArrow) {
|
|
87
|
+
executeBinding(currentBindings.onRightArrow, input, key);
|
|
49
88
|
return;
|
|
50
89
|
}
|
|
51
90
|
// 일반 키 입력 처리
|
|
52
|
-
if (input &&
|
|
53
|
-
|
|
91
|
+
if (input && currentBindings[input]) {
|
|
92
|
+
executeBinding(currentBindings[input], input, key);
|
|
93
|
+
}
|
|
94
|
+
else if (input && currentCount !== null) {
|
|
95
|
+
// 바인딩되지 않은 키가 입력되면 Count 초기화 (Vim 스타일)
|
|
96
|
+
dispatch(setNavigationCount(null));
|
|
54
97
|
}
|
|
55
|
-
}, [
|
|
98
|
+
}, [dispatch]);
|
|
56
99
|
useInput(handleInput, { isActive: isEnabled });
|
|
57
100
|
}
|
|
@@ -20,6 +20,14 @@ export declare class OpenAPIParserService {
|
|
|
20
20
|
* Swagger 2.0 Operation 객체에서 Endpoint 생성
|
|
21
21
|
*/
|
|
22
22
|
private createEndpointV2;
|
|
23
|
+
/**
|
|
24
|
+
* OpenAPI v3 Response 객체 정규화
|
|
25
|
+
*/
|
|
26
|
+
private normalizeResponseV3;
|
|
27
|
+
/**
|
|
28
|
+
* Swagger 2.0 Response 객체 정규화
|
|
29
|
+
*/
|
|
30
|
+
private normalizeResponseV2;
|
|
23
31
|
/**
|
|
24
32
|
* OpenAPI v3 파라미터를 정규화
|
|
25
33
|
*/
|
|
@@ -33,9 +41,9 @@ export declare class OpenAPIParserService {
|
|
|
33
41
|
*/
|
|
34
42
|
private normalizeRequestBodyV3;
|
|
35
43
|
/**
|
|
36
|
-
* 엔드포인트 필터링
|
|
44
|
+
* 엔드포인트 메서드별 필터링
|
|
37
45
|
*/
|
|
38
|
-
|
|
46
|
+
filterByMethod(endpoints: Endpoint[], method: HttpMethod | 'ALL'): Endpoint[];
|
|
39
47
|
/**
|
|
40
48
|
* 엔드포인트 검색
|
|
41
49
|
*/
|