@gomjellie/lazyapi 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +162 -0
  3. package/dist/App.d.ts +9 -0
  4. package/dist/App.js +65 -0
  5. package/dist/cli.d.ts +5 -0
  6. package/dist/cli.js +39 -0
  7. package/dist/components/common/ErrorMessage.d.ts +10 -0
  8. package/dist/components/common/ErrorMessage.js +17 -0
  9. package/dist/components/common/KeybindingFooter.d.ts +18 -0
  10. package/dist/components/common/KeybindingFooter.js +20 -0
  11. package/dist/components/common/LoadingSpinner.d.ts +9 -0
  12. package/dist/components/common/LoadingSpinner.js +15 -0
  13. package/dist/components/common/SearchInput.d.ts +14 -0
  14. package/dist/components/common/SearchInput.js +72 -0
  15. package/dist/components/common/StatusBar.d.ts +14 -0
  16. package/dist/components/common/StatusBar.js +36 -0
  17. package/dist/components/detail-view/DetailView.d.ts +5 -0
  18. package/dist/components/detail-view/DetailView.js +266 -0
  19. package/dist/components/detail-view/LeftPanel.d.ts +19 -0
  20. package/dist/components/detail-view/LeftPanel.js +363 -0
  21. package/dist/components/detail-view/ParameterForm.d.ts +17 -0
  22. package/dist/components/detail-view/ParameterForm.js +77 -0
  23. package/dist/components/detail-view/RightPanel.d.ts +22 -0
  24. package/dist/components/detail-view/RightPanel.js +251 -0
  25. package/dist/components/list-view/EndpointList.d.ts +12 -0
  26. package/dist/components/list-view/EndpointList.js +72 -0
  27. package/dist/components/list-view/Explorer.d.ts +13 -0
  28. package/dist/components/list-view/Explorer.js +83 -0
  29. package/dist/components/list-view/FilterBar.d.ts +12 -0
  30. package/dist/components/list-view/FilterBar.js +33 -0
  31. package/dist/components/list-view/ListView.d.ts +5 -0
  32. package/dist/components/list-view/ListView.js +304 -0
  33. package/dist/components/list-view/SearchBar.d.ts +11 -0
  34. package/dist/components/list-view/SearchBar.js +14 -0
  35. package/dist/hooks/useApiClient.d.ts +11 -0
  36. package/dist/hooks/useApiClient.js +34 -0
  37. package/dist/hooks/useKeyPress.d.ts +26 -0
  38. package/dist/hooks/useKeyPress.js +57 -0
  39. package/dist/hooks/useNavigation.d.ts +10 -0
  40. package/dist/hooks/useNavigation.js +33 -0
  41. package/dist/services/http-client.d.ts +21 -0
  42. package/dist/services/http-client.js +173 -0
  43. package/dist/services/openapi-parser.d.ts +47 -0
  44. package/dist/services/openapi-parser.js +318 -0
  45. package/dist/store/appSlice.d.ts +14 -0
  46. package/dist/store/appSlice.js +144 -0
  47. package/dist/store/hooks.d.ts +9 -0
  48. package/dist/store/hooks.js +7 -0
  49. package/dist/store/index.d.ts +12 -0
  50. package/dist/store/index.js +12 -0
  51. package/dist/types/app-state.d.ts +126 -0
  52. package/dist/types/app-state.js +4 -0
  53. package/dist/types/openapi.d.ts +86 -0
  54. package/dist/types/openapi.js +115 -0
  55. package/dist/utils/clipboard.d.ts +11 -0
  56. package/dist/utils/clipboard.js +26 -0
  57. package/dist/utils/formatters.d.ts +35 -0
  58. package/dist/utils/formatters.js +93 -0
  59. package/dist/utils/schema-formatter.d.ts +21 -0
  60. package/dist/utils/schema-formatter.js +301 -0
  61. package/dist/utils/validators.d.ts +16 -0
  62. package/dist/utils/validators.js +158 -0
  63. package/package.json +88 -0
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 파라미터 입력 폼 컴포넌트
3
+ */
4
+ import React, { useState, useEffect } from 'react';
5
+ import { Box, Text } from 'ink';
6
+ import TextInput from 'ink-text-input';
7
+ export default function ParameterForm({ endpoint, parameterValues, requestBody, focusedFieldIndex, mode, onParameterChange, onBodyChange, }) {
8
+ const [localValue, setLocalValue] = useState('');
9
+ // 파라미터를 타입별로 그룹화
10
+ const pathParams = endpoint.parameters.filter((p) => p.in === 'path');
11
+ const queryParams = endpoint.parameters.filter((p) => p.in === 'query');
12
+ const headerParams = endpoint.parameters.filter((p) => p.in === 'header');
13
+ const allFields = [
14
+ ...pathParams.map((p, i) => ({ type: 'path', param: p, index: i })),
15
+ ...queryParams.map((p, i) => ({ type: 'query', param: p, index: pathParams.length + i })),
16
+ ...headerParams.map((p, i) => ({
17
+ type: 'header',
18
+ param: p,
19
+ index: pathParams.length + queryParams.length + i,
20
+ })),
21
+ ];
22
+ // Body 필드가 있는지 확인
23
+ const hasBody = endpoint.requestBody !== undefined;
24
+ const bodyFieldIndex = allFields.length;
25
+ // 포커스 변경시 로컬 값 업데이트
26
+ useEffect(() => {
27
+ if (focusedFieldIndex < allFields.length) {
28
+ const field = allFields[focusedFieldIndex];
29
+ setLocalValue(parameterValues[field.param.name] || '');
30
+ }
31
+ else if (focusedFieldIndex === bodyFieldIndex) {
32
+ setLocalValue(requestBody);
33
+ }
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ }, [focusedFieldIndex]);
36
+ const renderParameter = (param, globalIndex, type) => {
37
+ const isFocused = globalIndex === focusedFieldIndex;
38
+ const value = parameterValues[param.name] || '';
39
+ return (React.createElement(Box, { key: param.name, flexDirection: "column", marginBottom: 1 },
40
+ React.createElement(Box, null,
41
+ React.createElement(Text, { color: isFocused ? 'green' : 'white', bold: isFocused },
42
+ param.name,
43
+ param.required && React.createElement(Text, { color: "red" }, "*")),
44
+ React.createElement(Text, { dimColor: true },
45
+ " (",
46
+ type,
47
+ ")"),
48
+ param.schema &&
49
+ typeof param.schema === 'object' &&
50
+ !('$ref' in param.schema) &&
51
+ param.schema.type && React.createElement(Text, { dimColor: true },
52
+ " - ",
53
+ param.schema.type)),
54
+ React.createElement(Box, { borderStyle: isFocused ? 'round' : 'single', borderColor: isFocused ? 'green' : 'gray', paddingX: 1 }, mode === 'INSERT' && isFocused ? (React.createElement(TextInput, { value: localValue, onChange: (val) => {
55
+ setLocalValue(val);
56
+ onParameterChange(param.name, val);
57
+ } })) : (React.createElement(Text, { color: value ? 'white' : 'gray' }, value || (param.example ? String(param.example) : '(empty)')))),
58
+ param.description && (React.createElement(Box, { marginLeft: 1 },
59
+ React.createElement(Text, { dimColor: true }, param.description)))));
60
+ };
61
+ return (React.createElement(Box, { flexDirection: "column" },
62
+ pathParams.length > 0 && (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
63
+ React.createElement(Text, { bold: true, color: "green" }, "\u25B8 Path Parameters"),
64
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2 }, pathParams.map((param, i) => renderParameter(param, i, 'path'))))),
65
+ queryParams.length > 0 && (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
66
+ React.createElement(Text, { bold: true, color: "green" }, "\u25B8 Query Parameters"),
67
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2 }, queryParams.map((param, i) => renderParameter(param, pathParams.length + i, 'query'))))),
68
+ headerParams.length > 0 && (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
69
+ React.createElement(Text, { bold: true, color: "green" }, "\u25B8 Headers"),
70
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2 }, headerParams.map((param, i) => renderParameter(param, pathParams.length + queryParams.length + i, 'header'))))),
71
+ hasBody && (React.createElement(Box, { flexDirection: "column" },
72
+ React.createElement(Text, { bold: true, color: "green" }, "\u25B8 Request Body"),
73
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, borderStyle: focusedFieldIndex === bodyFieldIndex ? 'round' : 'single', borderColor: focusedFieldIndex === bodyFieldIndex ? 'green' : 'gray', paddingX: 1 }, mode === 'INSERT' && focusedFieldIndex === bodyFieldIndex ? (React.createElement(TextInput, { value: localValue, onChange: (val) => {
74
+ setLocalValue(val);
75
+ onBodyChange(val);
76
+ } })) : (React.createElement(Text, { color: requestBody ? 'white' : 'gray' }, requestBody || '(empty)')))))));
77
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * API Detail 우측 패널 - Request/Response 분할
3
+ */
4
+ import React from 'react';
5
+ import { ApiResponse, AppMode } from '../../types/app-state.js';
6
+ import { Endpoint } from '../../types/openapi.js';
7
+ interface RightPanelProps {
8
+ activeRequestTab: 'headers' | 'body' | 'query';
9
+ activeResponseTab: 'body' | 'headers' | 'cookies' | 'curl';
10
+ activePanel: 'left' | 'request' | 'response';
11
+ response: ApiResponse | null;
12
+ endpoint: Endpoint;
13
+ parameterValues: Record<string, string>;
14
+ requestBody: string;
15
+ requestHeaders: Record<string, string>;
16
+ baseURL: string;
17
+ mode: AppMode;
18
+ isLoading: boolean;
19
+ commandMode?: boolean;
20
+ }
21
+ export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, response, endpoint, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode, }: RightPanelProps): React.JSX.Element;
22
+ export {};
@@ -0,0 +1,251 @@
1
+ /**
2
+ * API Detail 우측 패널 - Request/Response 분할
3
+ */
4
+ import React, { useState, useEffect, useMemo } from 'react';
5
+ import { Box, Text, useStdout } from 'ink';
6
+ import { useKeyPress } from '../../hooks/useKeyPress.js';
7
+ import { useAppDispatch } from '../../store/hooks.js';
8
+ import { detailSetPanel } from '../../store/appSlice.js';
9
+ 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) => {
11
+ let showHint = false;
12
+ let hintText = '';
13
+ const shouldShowGlobal = commandMode && !!shortcutPrefix;
14
+ const shouldShowLocal = commandMode && isFocused;
15
+ if (shouldShowGlobal) {
16
+ // Global shortcut (e.g., r1, r2) - Show always in command mode
17
+ showHint = true;
18
+ hintText = `[${shortcutPrefix}${index + 1}] `;
19
+ }
20
+ else if (shouldShowLocal) {
21
+ // Contextual shortcut (1, 2) - Show only when focused
22
+ showHint = true;
23
+ hintText = `[${index + 1}] `;
24
+ }
25
+ return (React.createElement(Box, { key: t.id },
26
+ 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 + ' ')));
28
+ })));
29
+ export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, response, endpoint, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode = false, }) {
30
+ const { stdout } = useStdout();
31
+ const dispatch = useAppDispatch();
32
+ // 높이 계산
33
+ const totalHeight = (stdout?.rows || 24) - 4;
34
+ const requestBoxHeight = Math.floor(totalHeight / 2);
35
+ const responseBoxHeight = totalHeight - requestBoxHeight; // 나머지 높이를 Response 박스에 할당
36
+ const contentHeight = Math.max(requestBoxHeight - 4, 1);
37
+ // -- Request Box State --
38
+ const [reqScroll, setReqScroll] = useState(0);
39
+ useEffect(() => {
40
+ // eslint-disable-next-line react-hooks/set-state-in-effect
41
+ setReqScroll(0);
42
+ }, [activeRequestTab, endpoint]);
43
+ // Request Content Lines 계산
44
+ const getRequestLines = () => {
45
+ if (activeRequestTab === 'headers') {
46
+ const keys = Object.keys(requestHeaders);
47
+ return keys.length === 0 ? 1 : keys.length;
48
+ }
49
+ if (activeRequestTab === 'body') {
50
+ return requestBody ? requestBody.split('\n').length : 1;
51
+ }
52
+ if (activeRequestTab === 'query') {
53
+ const queries = endpoint.parameters.filter((p) => p.in === 'query');
54
+ return queries.length === 0 ? 1 : queries.length;
55
+ }
56
+ return 0;
57
+ };
58
+ const reqTotalLines = getRequestLines();
59
+ // Request Scroll Handler (j/k)
60
+ useKeyPress({
61
+ 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
+ }
70
+ },
71
+ k: () => {
72
+ setReqScroll((prev) => Math.max(prev - 1, 0));
73
+ },
74
+ }, mode === 'NORMAL' && activePanel === 'request');
75
+ // -- Response Box State --
76
+ const [resScroll, setResScroll] = useState(0);
77
+ useEffect(() => {
78
+ // 탭이 변경될 때만 스크롤 리셋 (response 변경 시에는 리셋하지 않음)
79
+ // eslint-disable-next-line react-hooks/set-state-in-effect
80
+ setResScroll(0);
81
+ }, [activeResponseTab]);
82
+ // Curl Command 생성 (메모이제이션)
83
+ const curlCommand = useMemo(() => {
84
+ let path = endpoint.path;
85
+ endpoint.parameters
86
+ .filter((p) => p.in === 'path')
87
+ .forEach((param) => {
88
+ const value = parameterValues[param.name] || '';
89
+ path = path.replace(`{${param.name}}`, value);
90
+ });
91
+ const queryParams = endpoint.parameters
92
+ .filter((p) => p.in === 'query')
93
+ .filter((param) => parameterValues[param.name])
94
+ .map((param) => `${param.name}=${encodeURIComponent(parameterValues[param.name])}`)
95
+ .join('&');
96
+ const url = queryParams ? `${baseURL}${path}?${queryParams}` : `${baseURL}${path}`;
97
+ let headerStr = '';
98
+ Object.entries(requestHeaders).forEach(([key, val]) => {
99
+ headerStr += ` -H '${key}: ${val}'`;
100
+ });
101
+ let bodyStr = '';
102
+ if (requestBody) {
103
+ bodyStr = ` -d '${requestBody.replace(/'/g, "'\\''")}'`;
104
+ }
105
+ const method = endpoint.method.toUpperCase();
106
+ return `curl -X ${method} '${url}'${headerStr}${bodyStr}`;
107
+ }, [endpoint, parameterValues, requestHeaders, requestBody, baseURL]);
108
+ // Response Content Lines 계산 (메모이제이션)
109
+ const resTotalLines = useMemo(() => {
110
+ if (activeResponseTab === 'curl') {
111
+ return curlCommand.split('\n').length;
112
+ }
113
+ if (!response)
114
+ return 1;
115
+ if (activeResponseTab === 'body') {
116
+ const bodyStr = typeof response.body === 'string' ? response.body : JSON.stringify(response.body, null, 2);
117
+ return bodyStr.split('\n').length;
118
+ }
119
+ if (activeResponseTab === 'headers') {
120
+ return Object.keys(response.headers).length || 1;
121
+ }
122
+ if (activeResponseTab === 'cookies') {
123
+ return response.cookies ? Object.keys(response.cookies).length || 1 : 1;
124
+ }
125
+ return 0;
126
+ }, [activeResponseTab, response, curlCommand]);
127
+ // Response Scroll Handler (j/k)
128
+ useKeyPress({
129
+ j: () => {
130
+ const maxScroll = Math.max(0, resTotalLines - contentHeight);
131
+ if (resScroll < maxScroll) {
132
+ setResScroll((prev) => Math.min(prev + 1, maxScroll));
133
+ }
134
+ },
135
+ k: () => {
136
+ if (resScroll > 0) {
137
+ setResScroll((prev) => Math.max(prev - 1, 0));
138
+ }
139
+ else {
140
+ // 스크롤이 맨 위에 도달하면 Request 패널로 이동
141
+ dispatch(detailSetPanel('request'));
142
+ }
143
+ },
144
+ }, mode === 'NORMAL' && activePanel === 'response');
145
+ // 렌더링 헬퍼
146
+ const renderLines = (lines, offset, limit) => {
147
+ const visible = lines.slice(offset, offset + limit);
148
+ return visible.map((line, i) => React.createElement(Text, { key: offset + i }, line));
149
+ };
150
+ const renderKeyValue = (data, offset, limit) => {
151
+ const entries = Object.entries(data);
152
+ if (entries.length === 0)
153
+ return React.createElement(Text, { color: "gray" }, "No data");
154
+ const visible = entries.slice(offset, offset + limit);
155
+ return visible.map(([k, v], i) => (React.createElement(Box, { key: offset + i },
156
+ React.createElement(Text, { color: "green" },
157
+ k,
158
+ ": "),
159
+ React.createElement(Text, null, v))));
160
+ };
161
+ // Request Box Render
162
+ const renderRequestContent = () => {
163
+ if (activeRequestTab === 'headers') {
164
+ if (Object.keys(requestHeaders).length === 0)
165
+ return React.createElement(Text, { color: "gray" }, "No headers");
166
+ return renderKeyValue(requestHeaders, reqScroll, contentHeight);
167
+ }
168
+ if (activeRequestTab === 'body') {
169
+ if (!requestBody)
170
+ return React.createElement(Text, { color: "gray" }, "No body");
171
+ return renderLines(requestBody.split('\n'), reqScroll, contentHeight);
172
+ }
173
+ if (activeRequestTab === 'query') {
174
+ const queries = endpoint.parameters.filter((p) => p.in === 'query');
175
+ if (queries.length === 0)
176
+ return React.createElement(Text, { color: "gray" }, "No query parameters");
177
+ const visible = queries.slice(reqScroll, reqScroll + contentHeight);
178
+ return visible.map((q, i) => (React.createElement(Box, { key: reqScroll + i },
179
+ React.createElement(Text, { color: "blue" },
180
+ q.name,
181
+ ": "),
182
+ React.createElement(Text, null, parameterValues[q.name] || (React.createElement(Text, { color: "gray", dimColor: true }, "(empty)"))))));
183
+ }
184
+ return null;
185
+ };
186
+ // Response Box Render
187
+ const renderResponseContent = () => {
188
+ if (activeResponseTab === 'curl') {
189
+ return renderLines(curlCommand.split('\n'), resScroll, contentHeight);
190
+ }
191
+ if (isLoading) {
192
+ return (React.createElement(Box, null,
193
+ React.createElement(Text, { color: "green" },
194
+ React.createElement(Spinner, { type: "dots" })),
195
+ React.createElement(Text, { color: "green" }, " Sending request...")));
196
+ }
197
+ if (!response)
198
+ return React.createElement(Text, { color: "gray" }, "Press Enter to execute request.");
199
+ if (activeResponseTab === 'body') {
200
+ const bodyStr = typeof response.body === 'string' ? response.body : JSON.stringify(response.body, null, 2);
201
+ return renderLines(bodyStr.split('\n'), resScroll, contentHeight);
202
+ }
203
+ if (activeResponseTab === 'headers') {
204
+ if (!response.headers)
205
+ return React.createElement(Text, { color: "gray" }, "No headers");
206
+ return renderKeyValue(response.headers, resScroll, contentHeight);
207
+ }
208
+ if (activeResponseTab === 'cookies') {
209
+ if (!response.cookies || Object.keys(response.cookies).length === 0)
210
+ return React.createElement(Text, { color: "gray" }, "No cookies");
211
+ return renderKeyValue(response.cookies, resScroll, contentHeight);
212
+ }
213
+ return null;
214
+ };
215
+ // Tabs
216
+ const reqTabs = [
217
+ { id: 'headers', label: 'Headers' },
218
+ { id: 'body', label: 'Body' },
219
+ { id: 'query', label: 'Query' },
220
+ ];
221
+ const resTabs = [
222
+ { id: 'body', label: 'Body' },
223
+ { id: 'headers', label: 'Headers' },
224
+ { id: 'cookies', label: 'Cookies' },
225
+ { id: 'curl', label: 'Curl' },
226
+ ];
227
+ return (React.createElement(Box, { flexDirection: "column", width: "50%", marginLeft: 1 },
228
+ 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"),
231
+ React.createElement(Box, { width: 15 },
232
+ React.createElement(Text, { color: "gray" }, reqTotalLines > contentHeight
233
+ ? `${reqScroll + 1}-${Math.min(reqScroll + contentHeight, reqTotalLines)}/${reqTotalLines}`
234
+ : ' '))),
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())),
237
+ 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"),
240
+ React.createElement(Box, { width: 30, flexDirection: "row", justifyContent: "flex-end" },
241
+ response ? (React.createElement(Text, { color: response.status < 300 ? 'green' : 'red' },
242
+ response.status,
243
+ " ",
244
+ response.statusText)) : (React.createElement(Text, null, " ")),
245
+ React.createElement(Box, { width: 15 },
246
+ React.createElement(Text, { color: "gray" }, resTotalLines > contentHeight
247
+ ? `${resScroll + 1}-${Math.min(resScroll + contentHeight, resTotalLines)}/${resTotalLines}`
248
+ : ' ')))),
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()))));
251
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 엔드포인트 목록 컴포넌트
3
+ */
4
+ import React from 'react';
5
+ import { Endpoint } from '../../types/openapi.js';
6
+ interface EndpointListProps {
7
+ endpoints: Endpoint[];
8
+ selectedIndex: number;
9
+ commandMode?: boolean;
10
+ }
11
+ export default function EndpointList({ endpoints, selectedIndex, commandMode }: EndpointListProps): React.JSX.Element;
12
+ export {};
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 엔드포인트 목록 컴포넌트
3
+ */
4
+ import React, { useState } from 'react';
5
+ import { Box, Text, useStdout } from 'ink';
6
+ export default function EndpointList({ endpoints, selectedIndex, commandMode = false }) {
7
+ const { stdout } = useStdout();
8
+ // 가용 높이 계산: 터미널 높이 - 헤더(2) - 푸터(2) - 메인박스보더(2) - 메인박스패딩(1) - 필터바(1) - 검색바(1) = 9
9
+ // 안전하게 여유를 두어 계산 (-9)
10
+ const availableHeight = (stdout?.rows || 24) - 9;
11
+ // 가용 너비 계산 및 Description 컬럼 너비 산출
12
+ const terminalWidth = stdout?.columns || 80;
13
+ // Explorer 너비 동적 계산 (Explorer.tsx와 동일한 로직)
14
+ const explorerWidth = Math.min(Math.max(30, Math.floor(terminalWidth * 0.25)), 50);
15
+ // 레이아웃 고정 너비 요소들:
16
+ // Explorer(explorerWidth) + ExplorerMargin(1) + ListBorder(2) + ListPadding(2) + ItemPadding(2) + MethodMargin(1) + PathMargin(1)
17
+ const LAYOUT_OVERHEAD = explorerWidth + 1 + 2 + 2 + 2 + 1 + 1; // explorerWidth + 9
18
+ const METHOD_WIDTH = 10; // Reduced from 12
19
+ const availableContentWidth = Math.max(0, terminalWidth - LAYOUT_OVERHEAD - METHOD_WIDTH);
20
+ // Path에게 가용 공간의 70% 할당, Description에게 나머지 30% 할당
21
+ // Path가 중요하므로 더 많은 공간 배정
22
+ const pathWidth = Math.floor(availableContentWidth * 0.7);
23
+ const descriptionWidth = Math.max(0, availableContentWidth - pathWidth);
24
+ const [scrollOffset, setScrollOffset] = useState(0);
25
+ // 렌더링 중에 스크롤 오프셋 조정 (Derived State Pattern)
26
+ if (selectedIndex >= 0) {
27
+ const visibleStart = scrollOffset;
28
+ const visibleEnd = scrollOffset + availableHeight - 1;
29
+ if (selectedIndex < visibleStart) {
30
+ setScrollOffset(selectedIndex);
31
+ }
32
+ else if (selectedIndex > visibleEnd) {
33
+ setScrollOffset(selectedIndex - availableHeight + 1);
34
+ }
35
+ }
36
+ const getMethodColor = (method) => {
37
+ const colors = {
38
+ GET: 'blue',
39
+ POST: 'green',
40
+ PUT: 'yellow',
41
+ DELETE: 'red',
42
+ PATCH: 'magenta',
43
+ OPTIONS: 'cyan',
44
+ HEAD: 'gray',
45
+ TRACE: 'white',
46
+ };
47
+ return colors[method] || 'white';
48
+ };
49
+ if (endpoints.length === 0) {
50
+ return (React.createElement(Box, { justifyContent: "flex-start", alignItems: "flex-start", flexGrow: 1 },
51
+ React.createElement(Text, { dimColor: true }, "\uD83D\uDD75\uFE0F No endpoints found ")));
52
+ }
53
+ // 보이는 범위의 엔드포인트만 렌더링
54
+ const visibleEndpoints = endpoints.slice(scrollOffset, scrollOffset + availableHeight);
55
+ return (React.createElement(Box, { flexDirection: "column", paddingX: 0 }, visibleEndpoints.map((endpoint, visibleIndex) => {
56
+ const actualIndex = scrollOffset + visibleIndex;
57
+ const isSelected = actualIndex === selectedIndex;
58
+ // Method string formatted
59
+ const methodStr = `[${endpoint.method}]`;
60
+ return (React.createElement(Box, { key: endpoint.id, paddingX: 1, marginBottom: 0, flexDirection: "row", backgroundColor: isSelected ? 'green' : undefined },
61
+ commandMode && React.createElement(Text, { color: "yellow" },
62
+ "e",
63
+ actualIndex + 1,
64
+ " "),
65
+ React.createElement(Box, { width: METHOD_WIDTH, marginRight: 1 },
66
+ React.createElement(Text, { bold: isSelected, color: isSelected ? 'black' : getMethodColor(endpoint.method), backgroundColor: isSelected ? 'green' : undefined, wrap: "truncate" }, methodStr)),
67
+ React.createElement(Box, { width: pathWidth, marginRight: 1 },
68
+ React.createElement(Text, { bold: isSelected, color: isSelected ? 'black' : 'white', backgroundColor: isSelected ? 'green' : undefined, wrap: "truncate-end" }, endpoint.path)),
69
+ React.createElement(Box, { width: descriptionWidth },
70
+ React.createElement(Text, { dimColor: !isSelected, color: isSelected ? 'black' : undefined, backgroundColor: isSelected ? 'green' : undefined, wrap: "truncate-end" }, endpoint.summary || endpoint.description || ''))));
71
+ })));
72
+ }
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ interface ExplorerProps {
3
+ tags: string[];
4
+ selectedTagIndex: number;
5
+ activeTag: string | null;
6
+ isFocused: boolean;
7
+ searchQuery: string;
8
+ isSearchMode: boolean;
9
+ commandMode?: boolean;
10
+ onSearchChange: (query: string) => void;
11
+ }
12
+ export default function Explorer({ tags, selectedTagIndex, activeTag, isFocused, searchQuery, isSearchMode, commandMode, onSearchChange, }: ExplorerProps): React.JSX.Element;
13
+ export {};
@@ -0,0 +1,83 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text, useStdout } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ export default function Explorer({ tags, selectedTagIndex, activeTag, isFocused, searchQuery, isSearchMode, commandMode = false, onSearchChange, }) {
5
+ const { stdout } = useStdout();
6
+ const allItems = ['Overview', ...tags];
7
+ // 검색 필터링
8
+ const filteredItems = searchQuery
9
+ ? allItems.filter((item) => item.toLowerCase().includes(searchQuery.toLowerCase()))
10
+ : allItems;
11
+ // 가용 높이 계산: 터미널 높이 - 헤더(2) - 푸터(2) - Explorer박스보더(2) - Explorer박스패딩(1) - 타이틀(1) - 검색바/마진(1) = 9
12
+ // searchBarHeight가 1이면 margin이 0이므로 내부 overhead는 항상 2 (Title 1 + (Margin 1 or Search 1))
13
+ // 따라서 항상 -9를 하면 됨.
14
+ const availableHeight = (stdout?.rows || 24) - 9;
15
+ // 터미널 너비의 25% 사용 (최소 30, 최대 50)
16
+ const terminalWidth = stdout?.columns || 80;
17
+ const explorerWidth = Math.min(Math.max(30, Math.floor(terminalWidth * 0.25)), 50);
18
+ // 선택된 항목이 보이도록 스크롤 오프셋 상태 관리
19
+ const [scrollOffset, setScrollOffset] = useState(0);
20
+ // 스크롤 오프셋 계산 (Render 단계에서 상태 업데이트)
21
+ if (selectedTagIndex >= 0) {
22
+ let newOffset = scrollOffset;
23
+ // 리스트 전체 길이가 화면보다 작으면 스크롤 0
24
+ if (filteredItems.length <= availableHeight) {
25
+ newOffset = 0;
26
+ }
27
+ else {
28
+ // 위로 벗어난 경우
29
+ if (selectedTagIndex < newOffset) {
30
+ newOffset = selectedTagIndex;
31
+ }
32
+ // 아래로 벗어난 경우
33
+ else if (selectedTagIndex >= newOffset + availableHeight) {
34
+ newOffset = selectedTagIndex - availableHeight + 1;
35
+ }
36
+ // 범위 보정 (최대 스크롤 제한)
37
+ const maxOffset = filteredItems.length - availableHeight;
38
+ newOffset = Math.max(0, Math.min(newOffset, maxOffset));
39
+ }
40
+ if (newOffset !== scrollOffset) {
41
+ setScrollOffset(newOffset);
42
+ }
43
+ }
44
+ // 보이는 범위의 항목만 렌더링
45
+ const visibleItems = filteredItems.slice(scrollOffset, scrollOffset + availableHeight);
46
+ return (React.createElement(Box, { flexDirection: "column", width: explorerWidth, borderStyle: isFocused ? 'round' : 'single', borderColor: isFocused ? 'green' : 'gray', paddingX: 1, paddingBottom: 1, paddingTop: 0 },
47
+ React.createElement(Box, { marginBottom: isSearchMode || searchQuery ? 0 : 1 },
48
+ React.createElement(Text, { bold: true, color: "green", underline: true }, "TAG EXPLORER")),
49
+ (isSearchMode || searchQuery) && (React.createElement(Box, null,
50
+ React.createElement(Text, { color: "green" }, "\u279C "),
51
+ isSearchMode ? (React.createElement(TextInput, { value: searchQuery, onChange: onSearchChange, placeholder: "Search tags..." })) : (React.createElement(Text, { dimColor: true }, searchQuery)))),
52
+ React.createElement(Box, { flexDirection: "column" }, visibleItems.map((item, visibleIndex) => {
53
+ const actualIndex = scrollOffset + visibleIndex;
54
+ const isSelected = actualIndex === selectedTagIndex;
55
+ const isActive = (item === 'Overview' && activeTag === null) || item === activeTag;
56
+ // Selection cursor style (vim-like)
57
+ // If focused and selected: background green, black text
58
+ // If not focused but selected: maybe underline? or just keep it active.
59
+ // Actually, 'selectedTagIndex' tracks the cursor position in the explorer.
60
+ // 'activeTag' tracks which tag is currently filtering the list.
61
+ let backgroundColor = undefined;
62
+ let color = 'white';
63
+ if (isSelected && isFocused) {
64
+ backgroundColor = 'green';
65
+ color = 'black';
66
+ }
67
+ else if (isActive) {
68
+ color = 'green';
69
+ }
70
+ else {
71
+ color = 'gray';
72
+ }
73
+ return (React.createElement(Box, { key: item },
74
+ commandMode && (React.createElement(Box, { width: 5 },
75
+ React.createElement(Text, { color: "yellow" },
76
+ "t",
77
+ actualIndex + 1))),
78
+ React.createElement(Box, { width: 3 },
79
+ React.createElement(Text, { color: color, bold: isActive || isSelected }, "\uD83C\uDFF7\uFE0F")),
80
+ React.createElement(Box, { flexGrow: 1 },
81
+ React.createElement(Text, { backgroundColor: backgroundColor, color: color, bold: isActive || isSelected, wrap: "truncate-end" }, item))));
82
+ }))));
83
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * HTTP 메서드 필터 바 컴포넌트
3
+ */
4
+ import React from 'react';
5
+ import { HttpMethod } from '../../types/openapi.js';
6
+ interface FilterBarProps {
7
+ activeFilter: HttpMethod | 'ALL';
8
+ commandMode?: boolean;
9
+ onFilterChange: (filter: HttpMethod | 'ALL') => void;
10
+ }
11
+ export default function FilterBar({ activeFilter, commandMode, onFilterChange: _onFilterChange }: FilterBarProps): React.JSX.Element;
12
+ export {};
@@ -0,0 +1,33 @@
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 FilterBar({ activeFilter, commandMode = false, onFilterChange: _onFilterChange }) {
8
+ const filters = ['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 }, filters.slice(0, 6).map((filter, index) => {
25
+ const isActive = filter === activeFilter;
26
+ const color = getMethodColor(filter);
27
+ return (React.createElement(Box, { key: filter, gap: 0 },
28
+ commandMode && React.createElement(Text, { color: "yellow" },
29
+ "m",
30
+ index + 1),
31
+ React.createElement(Text, { backgroundColor: isActive ? color : undefined, color: isActive ? 'black' : 'gray', bold: isActive }, ' ' + filter + ' ')));
32
+ })));
33
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * API 목록 화면
3
+ */
4
+ import React from 'react';
5
+ export default function ListView(): React.JSX.Element;