@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.
@@ -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, }) => (React.createElement(Box, { marginBottom: 0, borderStyle: "single", borderBottom: false, borderTop: false, borderLeft: false, borderRight: false, gap: 1 }, tabs.map((t, index) => {
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
- const maxScroll = Math.max(0, reqTotalLines - contentHeight);
63
- if (reqScroll < maxScroll) {
64
- setReqScroll((prev) => Math.min(prev + 1, maxScroll));
65
- }
66
- else {
67
- // 스크롤이 끝에 도달하면 Response 패널로 이동
68
- dispatch(detailSetPanel('response'));
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 path = endpoint.path;
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
- path = path.replace(`{${param.name}}`, value);
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}${path}?${queryParams}` : `${baseURL}${path}`;
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
- const maxScroll = Math.max(0, resTotalLines - contentHeight);
131
- if (resScroll < maxScroll) {
132
- setResScroll((prev) => Math.min(prev + 1, maxScroll));
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
- if (resScroll > 0) {
137
- setResScroll((prev) => Math.max(prev - 1, 0));
138
- }
139
- else {
140
- // 스크롤이 맨 위에 도달하면 Request 패널로 이동
141
- dispatch(detailSetPanel('request'));
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
- if (!requestBody)
170
- return React.createElement(Text, { color: "gray" }, "No body");
171
- return renderLines(requestBody.split('\n'), reqScroll, contentHeight);
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, { useState } from '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
- if (selectedIndex >= 0) {
27
- const visibleStart = scrollOffset;
28
- const visibleEnd = scrollOffset + availableHeight - 1;
29
- if (selectedIndex < visibleStart) {
30
- setScrollOffset(selectedIndex);
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
- else if (selectedIndex > visibleEnd) {
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, listSetFilter, listSetTag, listSetFocus, listSelectTag, listSetSearch, listSetTagSearch, } from '../../store/appSlice.js';
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 FilterBar from './FilterBar.js';
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.activeFilter !== 'ALL') {
47
- filtered = parser.filterEndpoints(filtered, list.activeFilter);
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.activeFilter, list.searchQuery, list.activeTag, endpoints, parser]);
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
- // Filter (Method)
76
- const filters = ['ALL', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
77
- if (targetIndex >= 0 && targetIndex < filters.length) {
78
- dispatch(listSetFilter(filters[targetIndex]));
79
- dispatch(listSetFocus('LIST'));
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: Filter selection
103
- // 원래 LIST 포커스일 숫자 입력은 Filter를 선택하는 로직이었음.
104
- const filters = ['ALL', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
105
- if (targetIndex >= 0 && targetIndex < filters.length) {
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 newFocus = list.focus === 'LIST' ? 'TAGS' : 'LIST';
119
- dispatch(listSetFocus(newFocus));
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
- if (list.focus === 'TAGS') {
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
- if (list.focus === 'TAGS') {
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 selected = filteredEndpoints[list.selectedIndex];
200
- if (selected) {
201
- goToDetail(selected);
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
- if (list.focus === 'LIST') {
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(FilterBar, { activeFilter: list.activeFilter, commandMode: mode === 'COMMAND', onFilterChange: (filter) => dispatch(listSetFilter(filter)) })),
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
- if (!isEnabled)
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 && bindings.onShiftTab) {
16
- bindings.onShiftTab(input, key);
54
+ if (key.shift && currentBindings.onShiftTab) {
55
+ executeBinding(currentBindings.onShiftTab, input, key);
17
56
  }
18
- else if (bindings.onTab) {
19
- bindings.onTab(input, key);
57
+ else if (currentBindings.onTab) {
58
+ executeBinding(currentBindings.onTab, input, key);
20
59
  }
21
60
  return;
22
61
  }
23
- if (key.escape && bindings.onEscape) {
24
- bindings.onEscape(input, key);
62
+ if (key.escape && currentBindings.onEscape) {
63
+ executeBinding(currentBindings.onEscape, input, key);
25
64
  return;
26
65
  }
27
- if (key.return && bindings.onReturn) {
28
- bindings.onReturn(input, key);
66
+ if (key.return && currentBindings.onReturn) {
67
+ executeBinding(currentBindings.onReturn, input, key);
29
68
  return;
30
69
  }
31
- if (key.backspace && bindings.onBackspace) {
32
- bindings.onBackspace(input, key);
70
+ if (key.backspace && currentBindings.onBackspace) {
71
+ executeBinding(currentBindings.onBackspace, input, key);
33
72
  return;
34
73
  }
35
- if (key.upArrow && bindings.onUpArrow) {
36
- bindings.onUpArrow(input, key);
74
+ if (key.upArrow && currentBindings.onUpArrow) {
75
+ executeBinding(currentBindings.onUpArrow, input, key);
37
76
  return;
38
77
  }
39
- if (key.downArrow && bindings.onDownArrow) {
40
- bindings.onDownArrow(input, key);
78
+ if (key.downArrow && currentBindings.onDownArrow) {
79
+ executeBinding(currentBindings.onDownArrow, input, key);
41
80
  return;
42
81
  }
43
- if (key.leftArrow && bindings.onLeftArrow) {
44
- bindings.onLeftArrow(input, key);
82
+ if (key.leftArrow && currentBindings.onLeftArrow) {
83
+ executeBinding(currentBindings.onLeftArrow, input, key);
45
84
  return;
46
85
  }
47
- if (key.rightArrow && bindings.onRightArrow) {
48
- bindings.onRightArrow(input, key);
86
+ if (key.rightArrow && currentBindings.onRightArrow) {
87
+ executeBinding(currentBindings.onRightArrow, input, key);
49
88
  return;
50
89
  }
51
90
  // 일반 키 입력 처리
52
- if (input && bindings[input]) {
53
- bindings[input](input, key);
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
- }, [bindings, isEnabled]);
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
- filterEndpoints(endpoints: Endpoint[], method: HttpMethod | 'ALL'): Endpoint[];
46
+ filterByMethod(endpoints: Endpoint[], method: HttpMethod | 'ALL'): Endpoint[];
39
47
  /**
40
48
  * 엔드포인트 검색
41
49
  */