@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.
@@ -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, }) => (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;
@@ -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, { bold: t.id === active, color: t.id === active ? 'black' : 'gray', backgroundColor: t.id === active ? 'green' : undefined }, ' ' + t.label + ' ')));
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
- 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
- }
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) => Math.max(prev - 1, 0));
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 path = endpoint.path;
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
- path = path.replace(`{${param.name}}`, value);
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}${path}?${queryParams}` : `${baseURL}${path}`;
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
- const maxScroll = Math.max(0, resTotalLines - contentHeight);
131
- if (resScroll < maxScroll) {
132
- setResScroll((prev) => Math.min(prev + 1, maxScroll));
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
- if (resScroll > 0) {
137
- setResScroll((prev) => Math.max(prev - 1, 0));
138
- }
139
- else {
140
- // 스크롤이 맨 위에 도달하면 Request 패널로 이동
141
- dispatch(detailSetPanel('request'));
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
- if (!requestBody)
170
- return React.createElement(Text, { color: "gray" }, "No body");
171
- return renderLines(requestBody.split('\n'), reqScroll, contentHeight);
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(Text, { bold: true, color: activePanel === 'request' ? 'green' : 'white' }, "Request"),
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(Text, { bold: true, color: activePanel === 'response' ? 'green' : 'white' }, "Response"),
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, { 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',
@@ -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, 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,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' ? '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' || 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(FilterBar, { activeFilter: list.activeFilter, commandMode: mode === 'COMMAND', onFilterChange: (filter) => dispatch(listSetFilter(filter)) })),
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
+ }