@gomjellie/lazyapi 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -7
- package/dist/components/detail-view/DetailView.js +172 -10
- package/dist/components/detail-view/LeftPanel.d.ts +2 -1
- package/dist/components/detail-view/LeftPanel.js +39 -15
- package/dist/components/detail-view/RightPanel.d.ts +5 -5
- package/dist/components/detail-view/RightPanel.js +63 -236
- package/dist/components/list-view/ListView.js +8 -2
- package/dist/store/appSlice.d.ts +2 -2
- package/dist/store/appSlice.js +25 -2
- package/dist/store/navigationActions.js +107 -15
- package/dist/types/app-state.d.ts +6 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
# lazyapi
|
|
2
2
|
|
|
3
|
-

|
|
4
|
-
|
|
5
|
-
**⚠️ Work in Progress**
|
|
6
|
-
|
|
7
3
|
<div align="center">
|
|
8
4
|
|
|
9
|
-
A **Vim-style TUI (Terminal User Interface)** tool
|
|
5
|
+
A **Vim-style TUI (Terminal User Interface)** tool
|
|
6
|
+
for exploring Swagger/OpenAPI documents and testing APIs in the terminal
|
|
10
7
|
|
|
11
8
|
[](https://opensource.org/licenses/MIT)
|
|
12
9
|
[](https://nodejs.org)
|
|
13
10
|
|
|
11
|
+

|
|
12
|
+
|
|
14
13
|
</div>
|
|
15
14
|
|
|
16
15
|
---
|
|
@@ -57,7 +56,7 @@ lazyapi openapi.json
|
|
|
57
56
|
# YAML file
|
|
58
57
|
lazyapi swagger.yaml
|
|
59
58
|
|
|
60
|
-
# URL
|
|
59
|
+
# URL
|
|
61
60
|
lazyapi https://api.example.com/openapi.json
|
|
62
61
|
```
|
|
63
62
|
|
|
@@ -83,6 +82,7 @@ lazyapi https://api.example.com/openapi.json
|
|
|
83
82
|
| Key | Description |
|
|
84
83
|
| ---------- | ----------------------------------------------------- |
|
|
85
84
|
| Text input | Enter search query (searches path, description, tags) |
|
|
85
|
+
| `Enter` | Apply search and update results |
|
|
86
86
|
| `Esc` | Cancel search and return to NORMAL mode |
|
|
87
87
|
|
|
88
88
|
### Detail View
|
|
@@ -125,7 +125,6 @@ lazyapi https://api.example.com/openapi.json
|
|
|
125
125
|
|
|
126
126
|
## 📝 Future Plans
|
|
127
127
|
|
|
128
|
-
- [ ] Load OpenAPI documents from URL
|
|
129
128
|
- [ ] Multiple environment support (dev, staging, prod)
|
|
130
129
|
- [ ] Request history storage
|
|
131
130
|
- [ ] Favorites feature
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* API 상세 화면
|
|
3
3
|
*/
|
|
4
4
|
import React from 'react';
|
|
5
|
-
import { Box, Text, useStdout } from 'ink';
|
|
5
|
+
import { Box, Text, useStdout, useStdin } from 'ink';
|
|
6
6
|
import { useAppSelector, useAppDispatch } from '../../store/hooks.js';
|
|
7
|
-
import { setMode, detailSetFocus, detailSetPanel, detailSetRequestTab, detailSetResponseTab, detailSetResponse, detailSetParam, detailSetBody, } from '../../store/appSlice.js';
|
|
7
|
+
import { setMode, detailSetFocus, detailSetPanel, detailSetRequestTab, detailSetResponseTab, detailSetResponse, detailSetParam, detailSetBody, detailSetHeader, } from '../../store/appSlice.js';
|
|
8
8
|
import { navigateUp, navigateDown, navigateLeft, navigateRight, } from '../../store/navigationActions.js';
|
|
9
9
|
import { useNavigation } from '../../hooks/useNavigation.js';
|
|
10
10
|
import { useKeyPress } from '../../hooks/useKeyPress.js';
|
|
@@ -15,6 +15,10 @@ import LeftPanel from './LeftPanel.js';
|
|
|
15
15
|
import RightPanel from './RightPanel.js';
|
|
16
16
|
import { getServerUrl } from '../../types/openapi.js';
|
|
17
17
|
import { setCommandInput } from '../../store/appSlice.js';
|
|
18
|
+
import { spawnSync } from 'child_process';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import os from 'os';
|
|
21
|
+
import path from 'path';
|
|
18
22
|
export default function DetailView() {
|
|
19
23
|
const detail = useAppSelector((state) => state.app.detail);
|
|
20
24
|
const document = useAppSelector((state) => state.app.document);
|
|
@@ -24,11 +28,151 @@ export default function DetailView() {
|
|
|
24
28
|
const { goBack } = useNavigation();
|
|
25
29
|
const { executeRequest, loading } = useApiClient();
|
|
26
30
|
const { stdout } = useStdout();
|
|
31
|
+
const { isRawModeSupported, setRawMode } = useStdin();
|
|
27
32
|
// detail이 없는 경우를 대비한 기본값
|
|
28
33
|
const endpoint = detail?.endpoint;
|
|
29
34
|
const activePanel = detail?.activePanel || 'left';
|
|
30
35
|
const activeRequestTab = detail?.activeRequestTab || 'headers';
|
|
31
36
|
const activeResponseTab = detail?.activeResponseTab || 'body';
|
|
37
|
+
const handleOpenEditor = () => {
|
|
38
|
+
if (!detail || !endpoint)
|
|
39
|
+
return;
|
|
40
|
+
let content = '';
|
|
41
|
+
let fileName = `lazyapi-edit-${Date.now()}`;
|
|
42
|
+
let extension = '.json';
|
|
43
|
+
let isEditable = false;
|
|
44
|
+
let updateType = 'NONE';
|
|
45
|
+
// 1. Determine content and metadata based on active panel and tab
|
|
46
|
+
if (activePanel === 'request') {
|
|
47
|
+
if (activeRequestTab === 'body') {
|
|
48
|
+
content = detail.requestBody || '';
|
|
49
|
+
isEditable = true;
|
|
50
|
+
updateType = 'BODY';
|
|
51
|
+
}
|
|
52
|
+
else if (activeRequestTab === 'headers') {
|
|
53
|
+
content = JSON.stringify(detail.headers, null, 2);
|
|
54
|
+
isEditable = true; // Later could support bulk edit
|
|
55
|
+
updateType = 'HEADERS';
|
|
56
|
+
}
|
|
57
|
+
else if (activeRequestTab === 'query') {
|
|
58
|
+
content = JSON.stringify(detail.parameterValues, null, 2);
|
|
59
|
+
isEditable = true;
|
|
60
|
+
updateType = 'PARAMS';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else if (activePanel === 'response' && detail.response) {
|
|
64
|
+
if (activeResponseTab === 'body') {
|
|
65
|
+
content =
|
|
66
|
+
typeof detail.response.body === 'string'
|
|
67
|
+
? detail.response.body
|
|
68
|
+
: JSON.stringify(detail.response.body, null, 2);
|
|
69
|
+
}
|
|
70
|
+
else if (activeResponseTab === 'headers') {
|
|
71
|
+
content = JSON.stringify(detail.response.headers, null, 2);
|
|
72
|
+
}
|
|
73
|
+
else if (activeResponseTab === 'cookies') {
|
|
74
|
+
content = JSON.stringify(detail.response.cookies || {}, null, 2);
|
|
75
|
+
}
|
|
76
|
+
else if (activeResponseTab === 'curl') {
|
|
77
|
+
extension = '.sh';
|
|
78
|
+
// Calculate curl command
|
|
79
|
+
let pathStr = endpoint.path;
|
|
80
|
+
endpoint.parameters
|
|
81
|
+
.filter((p) => p.in === 'path')
|
|
82
|
+
.forEach((param) => {
|
|
83
|
+
const value = detail.parameterValues[param.name] || '';
|
|
84
|
+
pathStr = pathStr.replace(`{${param.name}}`, value);
|
|
85
|
+
});
|
|
86
|
+
const queryParams = endpoint.parameters
|
|
87
|
+
.filter((p) => p.in === 'query')
|
|
88
|
+
.filter((param) => detail.parameterValues[param.name])
|
|
89
|
+
.map((param) => `${param.name}=${encodeURIComponent(detail.parameterValues[param.name])}`)
|
|
90
|
+
.join('&');
|
|
91
|
+
const url = queryParams ? `${baseURL}${pathStr}?${queryParams}` : `${baseURL}${pathStr}`;
|
|
92
|
+
let headerStr = '';
|
|
93
|
+
Object.entries(detail.headers).forEach(([key, val]) => {
|
|
94
|
+
headerStr += ` -H '${key}: ${val}'`;
|
|
95
|
+
});
|
|
96
|
+
let bodyStr = '';
|
|
97
|
+
if (detail.requestBody) {
|
|
98
|
+
bodyStr = ` -d '${detail.requestBody.replace(/'/g, "'\\''")}'`;
|
|
99
|
+
}
|
|
100
|
+
const method = endpoint.method.toUpperCase();
|
|
101
|
+
content = `curl -X ${method} '${url}'${headerStr}${bodyStr}`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else if (activePanel === 'left') {
|
|
105
|
+
// LeftPanel handles body params or requestBody
|
|
106
|
+
content = detail.requestBody || '';
|
|
107
|
+
isEditable = true;
|
|
108
|
+
updateType = 'BODY';
|
|
109
|
+
}
|
|
110
|
+
if (!content && activePanel === 'response' && activeResponseTab === 'curl') {
|
|
111
|
+
// Re-calculate curl if needed or just show placeholder
|
|
112
|
+
content = 'Curl command calculation not available in this view';
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const tempFile = path.join(os.tmpdir(), `${fileName}${extension}`);
|
|
116
|
+
fs.writeFileSync(tempFile, content);
|
|
117
|
+
const editor = process.env.EDITOR || 'vim';
|
|
118
|
+
if (isRawModeSupported) {
|
|
119
|
+
setRawMode(false);
|
|
120
|
+
}
|
|
121
|
+
process.stdin.pause();
|
|
122
|
+
try {
|
|
123
|
+
spawnSync(editor, [tempFile], {
|
|
124
|
+
stdio: 'inherit',
|
|
125
|
+
});
|
|
126
|
+
if (fs.existsSync(tempFile) && isEditable) {
|
|
127
|
+
const newContent = fs.readFileSync(tempFile, 'utf-8');
|
|
128
|
+
if (newContent !== content) {
|
|
129
|
+
if (updateType === 'BODY') {
|
|
130
|
+
dispatch(detailSetBody(newContent));
|
|
131
|
+
}
|
|
132
|
+
else if (updateType === 'HEADERS') {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(newContent);
|
|
135
|
+
Object.entries(parsed).forEach(([key, value]) => {
|
|
136
|
+
dispatch(detailSetHeader({ key, value: String(value) }));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Ignore parse errors for bulk edit for now
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (updateType === 'PARAMS') {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(newContent);
|
|
146
|
+
Object.entries(parsed).forEach(([key, value]) => {
|
|
147
|
+
dispatch(detailSetParam({ key, value: String(value) }));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Ignore
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (fs.existsSync(tempFile)) {
|
|
157
|
+
fs.unlinkSync(tempFile);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (innerError) {
|
|
161
|
+
console.error('Error executing editor:', innerError);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
process.stdin.resume();
|
|
165
|
+
if (isRawModeSupported) {
|
|
166
|
+
setRawMode(true);
|
|
167
|
+
}
|
|
168
|
+
dispatch(setMode('NORMAL'));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
console.error('Failed to open editor', e);
|
|
173
|
+
dispatch(setMode('NORMAL'));
|
|
174
|
+
}
|
|
175
|
+
};
|
|
32
176
|
// 파라미터 필드 개수 계산
|
|
33
177
|
const pathParams = endpoint?.parameters.filter((p) => p.in === 'path') || [];
|
|
34
178
|
const queryParams = endpoint?.parameters.filter((p) => p.in === 'query') || [];
|
|
@@ -42,6 +186,23 @@ export default function DetailView() {
|
|
|
42
186
|
formDataParams.length +
|
|
43
187
|
(endpoint?.requestBody && bodyParams.length === 0 ? 1 : 0) +
|
|
44
188
|
(endpoint?.responses?.length || 0);
|
|
189
|
+
// Body 영역 인덱스 계산
|
|
190
|
+
const bodyStartIndex = pathParams.length + queryParams.length + headerParams.length;
|
|
191
|
+
const bodyEndIndex = bodyStartIndex + bodyParams.length;
|
|
192
|
+
// OA3 Request Body 인덱스 (Body Params가 없을 때만 존재)
|
|
193
|
+
const requestBodyIndex = endpoint?.requestBody && bodyParams.length === 0 ? bodyEndIndex + formDataParams.length : -1;
|
|
194
|
+
// 'e' 키 (Edit) 표시 여부 결정
|
|
195
|
+
const showEditKey =
|
|
196
|
+
// 1. Request or Response Panel > TABS area
|
|
197
|
+
((activePanel === 'request' || activePanel === 'response') &&
|
|
198
|
+
detail?.subFocus === 'TABS') ||
|
|
199
|
+
// 2. Left Panel > Body Parameter (in: body)
|
|
200
|
+
(activePanel === 'left' &&
|
|
201
|
+
detail &&
|
|
202
|
+
detail.focusedFieldIndex >= bodyStartIndex &&
|
|
203
|
+
detail.focusedFieldIndex < bodyEndIndex) ||
|
|
204
|
+
// 3. Left Panel > Request Body (OA3)
|
|
205
|
+
(activePanel === 'left' && detail && detail.focusedFieldIndex === requestBodyIndex);
|
|
45
206
|
const handleExecuteRequest = async () => {
|
|
46
207
|
if (!detail || !document)
|
|
47
208
|
return;
|
|
@@ -63,8 +224,8 @@ export default function DetailView() {
|
|
|
63
224
|
if (cmd === 'q' || cmd === 'quit' || cmd === 'exit') {
|
|
64
225
|
process.exit(0);
|
|
65
226
|
}
|
|
66
|
-
// 2. 접두사 있는 명령어 처리 (pp1, qp1, h1,
|
|
67
|
-
const match = cmd.match(/^(pp|qp|h|
|
|
227
|
+
// 2. 접두사 있는 명령어 처리 (pp1, qp1, h1, bt1, rs1, rq1, rt1...)
|
|
228
|
+
const match = cmd.match(/^(pp|qp|h|bt|rs|rq|rt)(\d+)$/);
|
|
68
229
|
if (match) {
|
|
69
230
|
const type = match[1];
|
|
70
231
|
const index = parseInt(match[2], 10);
|
|
@@ -100,8 +261,8 @@ export default function DetailView() {
|
|
|
100
261
|
dispatch(detailSetPanel('request'));
|
|
101
262
|
}
|
|
102
263
|
}
|
|
103
|
-
else if (type === '
|
|
104
|
-
// Request Body
|
|
264
|
+
else if (type === 'bt') {
|
|
265
|
+
// Request Body
|
|
105
266
|
const bodyStartIndex = pathParams.length + queryParams.length + headerParams.length;
|
|
106
267
|
if (bodyParams.length > 0) {
|
|
107
268
|
if (targetIndex >= 0 && targetIndex < bodyParams.length) {
|
|
@@ -117,8 +278,8 @@ export default function DetailView() {
|
|
|
117
278
|
}
|
|
118
279
|
}
|
|
119
280
|
}
|
|
120
|
-
else if (type === '
|
|
121
|
-
// Responses
|
|
281
|
+
else if (type === 'rt') {
|
|
282
|
+
// Responses
|
|
122
283
|
const responsesStartIndex = pathParams.length +
|
|
123
284
|
queryParams.length +
|
|
124
285
|
headerParams.length +
|
|
@@ -235,6 +396,7 @@ export default function DetailView() {
|
|
|
235
396
|
{ key: ':', description: 'Command' },
|
|
236
397
|
{ key: 'Tab', description: 'Cycle Panel' },
|
|
237
398
|
{ key: 'i', description: 'Insert' },
|
|
399
|
+
...(showEditKey ? [{ key: 'e', description: 'Editor' }] : []),
|
|
238
400
|
{ key: 'Enter', description: 'Execute' },
|
|
239
401
|
{ key: 'u', description: 'Back' },
|
|
240
402
|
]
|
|
@@ -260,7 +422,7 @@ export default function DetailView() {
|
|
|
260
422
|
dispatch(detailSetBody(value));
|
|
261
423
|
dispatch(detailSetPanel('request'));
|
|
262
424
|
dispatch(detailSetRequestTab('body'));
|
|
263
|
-
}, onSetMode: (m) => dispatch(setMode(m)) }),
|
|
264
|
-
React.createElement(RightPanel, { activeRequestTab: activeRequestTab, activeResponseTab: activeResponseTab, activePanel: activePanel, response: detail.response, endpoint: endpoint,
|
|
425
|
+
}, onSetMode: (m) => dispatch(setMode(m)), onOpenEditor: handleOpenEditor }),
|
|
426
|
+
React.createElement(RightPanel, { activeRequestTab: activeRequestTab, activeResponseTab: activeResponseTab, activePanel: activePanel, subFocus: detail.subFocus, response: detail.response, endpoint: endpoint, parameterValues: detail.parameterValues, requestBody: detail.requestBody, requestHeaders: detail.headers, baseURL: baseURL, isLoading: loading, commandMode: mode === 'COMMAND', onOpenEditor: handleOpenEditor })),
|
|
265
427
|
React.createElement(KeybindingFooter, { bindings: keyBindings, mode: mode, commandInput: commandInput, onCommandChange: (value) => dispatch(setCommandInput(value)), onCommandSubmit: handleCommandSubmit })));
|
|
266
428
|
}
|
|
@@ -17,6 +17,7 @@ interface LeftPanelProps {
|
|
|
17
17
|
onExitInsertMode?: () => void;
|
|
18
18
|
onRequestBodyEdit?: (value: string) => void;
|
|
19
19
|
onSetMode?: (mode: AppMode) => void;
|
|
20
|
+
onOpenEditor?: () => void;
|
|
20
21
|
}
|
|
21
|
-
export default function LeftPanel({ endpoint, parameterValues, requestBodyValue, focusedFieldIndex, mode, isFocused, document, commandMode, onParameterChange, onExitInsertMode, onRequestBodyEdit, onSetMode, }: LeftPanelProps): React.JSX.Element;
|
|
22
|
+
export default function LeftPanel({ endpoint, parameterValues, requestBodyValue, focusedFieldIndex, mode, isFocused, document, commandMode, onParameterChange, onExitInsertMode, onRequestBodyEdit, onSetMode, onOpenEditor, }: LeftPanelProps): React.JSX.Element;
|
|
22
23
|
export {};
|
|
@@ -35,7 +35,7 @@ function getStatusColor(status) {
|
|
|
35
35
|
return 'red';
|
|
36
36
|
return 'gray';
|
|
37
37
|
}
|
|
38
|
-
export default function LeftPanel({ endpoint, parameterValues, requestBodyValue, focusedFieldIndex, mode, isFocused, document, commandMode = false, onParameterChange, onExitInsertMode, onRequestBodyEdit, onSetMode, }) {
|
|
38
|
+
export default function LeftPanel({ endpoint, parameterValues, requestBodyValue, focusedFieldIndex, mode, isFocused, document, commandMode = false, onParameterChange, onExitInsertMode, onRequestBodyEdit, onSetMode, onOpenEditor, }) {
|
|
39
39
|
const { stdout } = useStdout();
|
|
40
40
|
const [localValue, setLocalValue] = useState('');
|
|
41
41
|
const [collapsedSchemas, setCollapsedSchemas] = useState(() => {
|
|
@@ -429,12 +429,23 @@ export default function LeftPanel({ endpoint, parameterValues, requestBodyValue,
|
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
431
|
};
|
|
432
|
+
const handleOpenRequestBodyEditor = () => {
|
|
433
|
+
if (!isFocused || mode !== 'NORMAL' || !onOpenEditor)
|
|
434
|
+
return;
|
|
435
|
+
const flatIndex = paramIndexToFlatIndex[focusedFieldIndex];
|
|
436
|
+
const item = flatItems[flatIndex];
|
|
437
|
+
const isBodyParam = item?.type === 'requestBody' || (item?.type === 'param' && item.param.in === 'body');
|
|
438
|
+
if (isBodyParam) {
|
|
439
|
+
onOpenEditor();
|
|
440
|
+
}
|
|
441
|
+
};
|
|
432
442
|
useKeyPress({
|
|
433
443
|
'>': handleExpandSchema,
|
|
434
444
|
'<': handleCollapseSchema,
|
|
435
445
|
j: handleSchemaScrollDown,
|
|
436
446
|
k: handleSchemaScrollUp,
|
|
437
447
|
i: handleEnterInsertMode,
|
|
448
|
+
e: handleOpenRequestBodyEditor,
|
|
438
449
|
}, mode === 'NORMAL' && isFocused);
|
|
439
450
|
// Render Helpers
|
|
440
451
|
const getSchemaName = (schema) => {
|
|
@@ -530,7 +541,7 @@ export default function LeftPanel({ endpoint, parameterValues, requestBodyValue,
|
|
|
530
541
|
if (item.param.schema) {
|
|
531
542
|
if (isReferenceObject(item.param.schema)) {
|
|
532
543
|
refInfo = item.param.schema.$ref.split('/').pop() || item.param.schema.$ref;
|
|
533
|
-
typeInfo =
|
|
544
|
+
typeInfo = refInfo;
|
|
534
545
|
}
|
|
535
546
|
else {
|
|
536
547
|
const schemaType = item.param.schema.type;
|
|
@@ -557,8 +568,8 @@ export default function LeftPanel({ endpoint, parameterValues, requestBodyValue,
|
|
|
557
568
|
commandHint = `h${hIndex}`;
|
|
558
569
|
}
|
|
559
570
|
else if (item.param.in === 'body') {
|
|
560
|
-
const
|
|
561
|
-
commandHint = `
|
|
571
|
+
const btIndex = item.globalIndex - bodyStartIndex + 1;
|
|
572
|
+
commandHint = `bt${btIndex}`;
|
|
562
573
|
}
|
|
563
574
|
}
|
|
564
575
|
return (React.createElement(Box, { key: key, flexDirection: "column" },
|
|
@@ -581,7 +592,11 @@ export default function LeftPanel({ endpoint, parameterValues, requestBodyValue,
|
|
|
581
592
|
React.createElement(Box, { marginLeft: 2, flexGrow: 1 }, mode === 'INSERT' && isFieldFocused ? (React.createElement(SearchInput, { value: localValue, onChange: (val) => {
|
|
582
593
|
setLocalValue(val);
|
|
583
594
|
onParameterChange(item.param.name, val);
|
|
584
|
-
}, suggestions: [], placeholder: "", focus: true, onSubmit: onExitInsertMode })) : (React.createElement(Text, { color: value ? 'white' : 'gray', backgroundColor: isFieldFocused ? 'green' : undefined, bold: isFieldFocused && !value },
|
|
595
|
+
}, suggestions: [], placeholder: "", focus: true, onSubmit: onExitInsertMode })) : (React.createElement(Text, { color: value ? 'white' : 'gray', backgroundColor: isFieldFocused ? 'green' : undefined, bold: isFieldFocused && !value }, item.param.in === 'body' && value
|
|
596
|
+
? isFieldFocused
|
|
597
|
+
? ' (Press e to edit) '
|
|
598
|
+
: '(Value set)'
|
|
599
|
+
: value || (isFieldFocused ? ' (empty) ' : '(empty)'))))),
|
|
585
600
|
item.param.description && (React.createElement(Box, { marginLeft: 2, marginTop: 0 },
|
|
586
601
|
React.createElement(Text, { color: "gray", dimColor: true }, item.param.description))),
|
|
587
602
|
(() => {
|
|
@@ -625,15 +640,24 @@ export default function LeftPanel({ endpoint, parameterValues, requestBodyValue,
|
|
|
625
640
|
const isFieldFocused = item.globalIndex === focusedFieldIndex && isFocused;
|
|
626
641
|
const isExpanded = !collapsedSchemas.has('__requestBody__');
|
|
627
642
|
const hasSchema = !!endpoint.requestBody?.schema;
|
|
628
|
-
const commandHint = commandMode ? '
|
|
643
|
+
const commandHint = commandMode ? 'bt1' : '';
|
|
629
644
|
return (React.createElement(Box, { key: key, flexDirection: "column" },
|
|
630
|
-
React.createElement(Box, { flexDirection: "row",
|
|
631
|
-
|
|
632
|
-
React.createElement(
|
|
633
|
-
"
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
645
|
+
React.createElement(Box, { flexDirection: "row", marginLeft: 1 },
|
|
646
|
+
React.createElement(Box, { flexDirection: "row", backgroundColor: isFieldFocused ? 'green' : undefined },
|
|
647
|
+
commandMode && (React.createElement(Box, { width: 8 },
|
|
648
|
+
React.createElement(Text, { color: "yellow" },
|
|
649
|
+
"[",
|
|
650
|
+
commandHint,
|
|
651
|
+
"]"))),
|
|
652
|
+
React.createElement(Text, { color: isFieldFocused ? 'black' : 'gray' }, item.endpoint.requestBody?.description || 'Request Body Content')),
|
|
653
|
+
React.createElement(Box, { marginLeft: 2 },
|
|
654
|
+
React.createElement(Text, { color: requestBodyValue ? 'white' : 'gray', backgroundColor: isFieldFocused ? 'green' : undefined, bold: isFieldFocused && !requestBodyValue }, requestBodyValue
|
|
655
|
+
? isFieldFocused
|
|
656
|
+
? ' (Press e to edit) '
|
|
657
|
+
: '(Value set)'
|
|
658
|
+
: isFieldFocused
|
|
659
|
+
? ' (empty) '
|
|
660
|
+
: '(empty)'))),
|
|
637
661
|
hasSchema && !isExpanded && (React.createElement(Box, { marginLeft: 1, marginTop: 0 },
|
|
638
662
|
React.createElement(Text, { color: "yellow", dimColor: true },
|
|
639
663
|
"Press > to view ",
|
|
@@ -661,13 +685,13 @@ export default function LeftPanel({ endpoint, parameterValues, requestBodyValue,
|
|
|
661
685
|
(endpoint.requestBody && endpoint.parameters.filter((p) => p.in === 'body').length === 0
|
|
662
686
|
? 1
|
|
663
687
|
: 0);
|
|
664
|
-
const
|
|
688
|
+
const rtIndex = item.globalIndex - responsesStartIndex + 1;
|
|
665
689
|
return (React.createElement(Box, { key: key, flexDirection: "column" },
|
|
666
690
|
React.createElement(Box, { flexDirection: "row", backgroundColor: isFieldFocused ? 'green' : undefined, marginLeft: 1 },
|
|
667
691
|
commandMode && (React.createElement(Box, { width: 8 },
|
|
668
692
|
React.createElement(Text, { color: "yellow" },
|
|
669
693
|
"[",
|
|
670
|
-
`
|
|
694
|
+
`rt${rtIndex}`,
|
|
671
695
|
"]"))),
|
|
672
696
|
React.createElement(Text, { color: isFieldFocused ? 'black' : getStatusColor(item.response.status), bold: true }, item.response.status),
|
|
673
697
|
React.createElement(Text, { color: isFieldFocused ? 'black' : 'white' },
|
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
* API Detail 우측 패널 - Request/Response 분할
|
|
3
3
|
*/
|
|
4
4
|
import React from 'react';
|
|
5
|
-
import { ApiResponse
|
|
6
|
-
import { Endpoint
|
|
5
|
+
import { ApiResponse } from '../../types/app-state.js';
|
|
6
|
+
import { Endpoint } from '../../types/openapi.js';
|
|
7
7
|
interface RightPanelProps {
|
|
8
8
|
activeRequestTab: 'headers' | 'body' | 'query';
|
|
9
9
|
activeResponseTab: 'body' | 'headers' | 'cookies' | 'curl';
|
|
10
10
|
activePanel: 'left' | 'request' | 'response';
|
|
11
|
+
subFocus: 'TITLE' | 'TABS';
|
|
11
12
|
response: ApiResponse | null;
|
|
12
13
|
endpoint: Endpoint;
|
|
13
|
-
document?: OpenAPIDocument;
|
|
14
14
|
parameterValues: Record<string, string>;
|
|
15
15
|
requestBody: string;
|
|
16
16
|
requestHeaders: Record<string, string>;
|
|
17
17
|
baseURL: string;
|
|
18
|
-
mode: AppMode;
|
|
19
18
|
isLoading: boolean;
|
|
20
19
|
commandMode?: boolean;
|
|
20
|
+
onOpenEditor: () => void;
|
|
21
21
|
}
|
|
22
|
-
export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, response, endpoint,
|
|
22
|
+
export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, subFocus, response, endpoint, parameterValues, requestBody, requestHeaders, baseURL, isLoading, commandMode, onOpenEditor, }: RightPanelProps): React.JSX.Element;
|
|
23
23
|
export {};
|
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API Detail 우측 패널 - Request/Response 분할
|
|
3
3
|
*/
|
|
4
|
-
import React, {
|
|
5
|
-
import { Box, Text, useStdout
|
|
4
|
+
import React, { useMemo } from 'react';
|
|
5
|
+
import { Box, Text, useStdout } from 'ink';
|
|
6
6
|
import { useKeyPress } from '../../hooks/useKeyPress.js';
|
|
7
|
-
import { useAppDispatch } from '../../store/hooks.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';
|
|
14
7
|
import Spinner from 'ink-spinner';
|
|
15
8
|
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) => {
|
|
16
9
|
let showHint = false;
|
|
@@ -27,127 +20,25 @@ const TabHeader = ({ tabs, active, isFocused, shortcutPrefix, commandMode }) =>
|
|
|
27
20
|
showHint = true;
|
|
28
21
|
hintText = `[${index + 1}] `;
|
|
29
22
|
}
|
|
23
|
+
const isActive = t.id === active;
|
|
24
|
+
const showIndicator = isFocused && isActive;
|
|
30
25
|
return (React.createElement(Box, { key: t.id },
|
|
31
26
|
showHint && React.createElement(Text, { color: "yellow" }, hintText),
|
|
32
|
-
React.createElement(Text, {
|
|
27
|
+
React.createElement(Text, { color: "yellow" }, showIndicator ? '>' : ' '),
|
|
28
|
+
React.createElement(Text, { bold: isActive, underline: showIndicator, color: isActive ? 'black' : 'gray', backgroundColor: isActive ? 'green' : undefined }, t.label + ' ')));
|
|
33
29
|
})));
|
|
34
|
-
export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, response, endpoint,
|
|
30
|
+
export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, subFocus, response, endpoint, parameterValues, requestBody, requestHeaders, baseURL, isLoading, commandMode = false, onOpenEditor, }) {
|
|
35
31
|
const { stdout } = useStdout();
|
|
36
|
-
const { isRawModeSupported, setRawMode } = useStdin();
|
|
37
|
-
const dispatch = useAppDispatch();
|
|
38
32
|
// 높이 계산
|
|
39
33
|
const totalHeight = (stdout?.rows || 24) - 4;
|
|
40
34
|
const requestBoxHeight = Math.floor(totalHeight / 2);
|
|
41
35
|
const responseBoxHeight = totalHeight - requestBoxHeight; // 나머지 높이를 Response 박스에 할당
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
setReqScroll(0);
|
|
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]);
|
|
68
|
-
// Request Content Lines 계산
|
|
69
|
-
const getRequestLines = () => {
|
|
70
|
-
if (activeRequestTab === 'headers') {
|
|
71
|
-
const keys = Object.keys(requestHeaders);
|
|
72
|
-
return keys.length === 0 ? 1 : keys.length;
|
|
73
|
-
}
|
|
74
|
-
if (activeRequestTab === 'body') {
|
|
75
|
-
return requestBody ? requestBody.split('\n').length : 1;
|
|
76
|
-
}
|
|
77
|
-
if (activeRequestTab === 'query') {
|
|
78
|
-
const queries = endpoint.parameters.filter((p) => p.in === 'query');
|
|
79
|
-
return queries.length === 0 ? 1 : queries.length;
|
|
80
|
-
}
|
|
81
|
-
return 0;
|
|
82
|
-
};
|
|
83
|
-
const reqTotalLines = getRequestLines();
|
|
84
|
-
// Request Scroll Handler (j/k)
|
|
85
|
-
useKeyPress({
|
|
86
|
-
j: () => {
|
|
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
|
-
});
|
|
98
|
-
},
|
|
99
|
-
k: () => {
|
|
100
|
-
setReqScroll((prev) => Math.max(prev - 1, 0));
|
|
101
|
-
},
|
|
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 모두)
|
|
36
|
+
// 콘텐츠 가용 높이 계산 (박스 테두리 2 + 제목 1 + 탭 1 + 하단 안내 1 = 5줄 제외)
|
|
37
|
+
const contentHeight = Math.max(requestBoxHeight - 5, 1);
|
|
38
|
+
// e 키: 해당 패널의 TABS에 포커스가 있을 때만 동작
|
|
142
39
|
useKeyPress({
|
|
143
|
-
e:
|
|
144
|
-
}, activePanel
|
|
145
|
-
// -- Response Box State --
|
|
146
|
-
const [resScroll, setResScroll] = useState(0);
|
|
147
|
-
useEffect(() => {
|
|
148
|
-
// 탭이 변경될 때만 스크롤 리셋 (response 변경 시에는 리셋하지 않음)
|
|
149
|
-
setResScroll(0);
|
|
150
|
-
}, [activeResponseTab]);
|
|
40
|
+
e: onOpenEditor,
|
|
41
|
+
}, activePanel !== 'left' && subFocus === 'TABS');
|
|
151
42
|
// Curl Command 생성 (메모이제이션)
|
|
152
43
|
const curlCommand = useMemo(() => {
|
|
153
44
|
let pathStr = endpoint.path;
|
|
@@ -174,107 +65,41 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
174
65
|
const method = endpoint.method.toUpperCase();
|
|
175
66
|
return `curl -X ${method} '${url}'${headerStr}${bodyStr}`;
|
|
176
67
|
}, [endpoint, parameterValues, requestHeaders, requestBody, baseURL]);
|
|
177
|
-
//
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return 1;
|
|
184
|
-
if (activeResponseTab === 'body') {
|
|
185
|
-
const bodyStr = typeof response.body === 'string' ? response.body : JSON.stringify(response.body, null, 2);
|
|
186
|
-
return bodyStr.split('\n').length;
|
|
187
|
-
}
|
|
188
|
-
if (activeResponseTab === 'headers') {
|
|
189
|
-
return Object.keys(response.headers).length || 1;
|
|
190
|
-
}
|
|
191
|
-
if (activeResponseTab === 'cookies') {
|
|
192
|
-
return response.cookies ? Object.keys(response.cookies).length || 1 : 1;
|
|
193
|
-
}
|
|
194
|
-
return 0;
|
|
195
|
-
}, [activeResponseTab, response, curlCommand]);
|
|
196
|
-
// Response Scroll Handler (j/k)
|
|
197
|
-
useKeyPress({
|
|
198
|
-
j: () => {
|
|
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
|
-
});
|
|
206
|
-
},
|
|
207
|
-
k: () => {
|
|
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
|
-
});
|
|
218
|
-
},
|
|
219
|
-
}, mode === 'NORMAL' && activePanel === 'response');
|
|
220
|
-
// 렌더링 헬퍼
|
|
221
|
-
const renderLines = (lines, offset, limit) => {
|
|
222
|
-
const visible = lines.slice(offset, offset + limit);
|
|
223
|
-
return visible.map((line, i) => React.createElement(Text, { key: offset + i }, line));
|
|
224
|
-
};
|
|
225
|
-
const renderKeyValue = (data, offset, limit) => {
|
|
226
|
-
const entries = Object.entries(data);
|
|
227
|
-
if (entries.length === 0)
|
|
228
|
-
return React.createElement(Text, { color: "gray" }, "No data");
|
|
229
|
-
const visible = entries.slice(offset, offset + limit);
|
|
230
|
-
return visible.map(([k, v], i) => (React.createElement(Box, { key: offset + i },
|
|
231
|
-
React.createElement(Text, { color: "green" },
|
|
232
|
-
k,
|
|
233
|
-
": "),
|
|
234
|
-
React.createElement(Text, null, v))));
|
|
68
|
+
// 렌더링 헬퍼 (스크롤 제거, 단순히 잘라서 표시)
|
|
69
|
+
const renderLines = (lines, limit) => {
|
|
70
|
+
const visible = lines.slice(0, limit);
|
|
71
|
+
return visible.map((line, i) => (React.createElement(Text, { key: i, wrap: "truncate" },
|
|
72
|
+
' ',
|
|
73
|
+
line)));
|
|
235
74
|
};
|
|
236
|
-
// Request Box Render
|
|
75
|
+
// Request Box Content Render
|
|
237
76
|
const renderRequestContent = () => {
|
|
238
77
|
if (activeRequestTab === 'headers') {
|
|
239
78
|
if (Object.keys(requestHeaders).length === 0)
|
|
240
|
-
return React.createElement(Text, { color: "gray" }, "No headers");
|
|
241
|
-
|
|
79
|
+
return React.createElement(Text, { color: "gray" }, " No headers");
|
|
80
|
+
const json = JSON.stringify(requestHeaders, null, 2);
|
|
81
|
+
return renderLines(json.split('\n'), contentHeight);
|
|
242
82
|
}
|
|
243
83
|
if (activeRequestTab === 'body') {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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;
|
|
84
|
+
if (!requestBody)
|
|
85
|
+
return React.createElement(Text, { color: "gray" }, " No body");
|
|
86
|
+
const lines = requestBody.split('\n');
|
|
87
|
+
return renderLines(lines, contentHeight);
|
|
260
88
|
}
|
|
261
89
|
if (activeRequestTab === 'query') {
|
|
262
90
|
const queries = endpoint.parameters.filter((p) => p.in === 'query');
|
|
263
91
|
if (queries.length === 0)
|
|
264
|
-
return React.createElement(Text, { color: "gray" }, "No query parameters");
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
q.name,
|
|
269
|
-
": "),
|
|
270
|
-
React.createElement(Text, null, parameterValues[q.name] || (React.createElement(Text, { color: "gray", dimColor: true }, "(empty)"))))));
|
|
92
|
+
return React.createElement(Text, { color: "gray" }, " No query parameters");
|
|
93
|
+
// 파라미터 값만 추출하여 JSON으로 표시
|
|
94
|
+
const json = JSON.stringify(parameterValues, null, 2);
|
|
95
|
+
return renderLines(json.split('\n'), contentHeight);
|
|
271
96
|
}
|
|
272
97
|
return null;
|
|
273
98
|
};
|
|
274
|
-
// Response Box Render
|
|
99
|
+
// Response Box Content Render
|
|
275
100
|
const renderResponseContent = () => {
|
|
276
101
|
if (activeResponseTab === 'curl') {
|
|
277
|
-
return renderLines(curlCommand.split('\n'),
|
|
102
|
+
return renderLines(curlCommand.split('\n'), contentHeight);
|
|
278
103
|
}
|
|
279
104
|
if (isLoading) {
|
|
280
105
|
return (React.createElement(Box, null,
|
|
@@ -283,20 +108,22 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
283
108
|
React.createElement(Text, { color: "green" }, " Sending request...")));
|
|
284
109
|
}
|
|
285
110
|
if (!response)
|
|
286
|
-
return React.createElement(Text, { color: "gray" }, "Press Enter to execute request.");
|
|
111
|
+
return React.createElement(Text, { color: "gray" }, " Press Enter to execute request.");
|
|
287
112
|
if (activeResponseTab === 'body') {
|
|
288
113
|
const bodyStr = typeof response.body === 'string' ? response.body : JSON.stringify(response.body, null, 2);
|
|
289
|
-
return renderLines(bodyStr.split('\n'),
|
|
114
|
+
return renderLines(bodyStr.split('\n'), contentHeight);
|
|
290
115
|
}
|
|
291
116
|
if (activeResponseTab === 'headers') {
|
|
292
117
|
if (!response.headers)
|
|
293
|
-
return React.createElement(Text, { color: "gray" }, "No headers");
|
|
294
|
-
|
|
118
|
+
return React.createElement(Text, { color: "gray" }, " No headers");
|
|
119
|
+
const json = JSON.stringify(response.headers, null, 2);
|
|
120
|
+
return renderLines(json.split('\n'), contentHeight);
|
|
295
121
|
}
|
|
296
122
|
if (activeResponseTab === 'cookies') {
|
|
297
123
|
if (!response.cookies || Object.keys(response.cookies).length === 0)
|
|
298
|
-
return React.createElement(Text, { color: "gray" }, "No cookies");
|
|
299
|
-
|
|
124
|
+
return React.createElement(Text, { color: "gray" }, " No cookies");
|
|
125
|
+
const json = JSON.stringify(response.cookies, null, 2);
|
|
126
|
+
return renderLines(json.split('\n'), contentHeight);
|
|
300
127
|
}
|
|
301
128
|
return null;
|
|
302
129
|
};
|
|
@@ -312,28 +139,28 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
|
|
|
312
139
|
{ id: 'cookies', label: 'Cookies' },
|
|
313
140
|
{ id: 'curl', label: 'Curl' },
|
|
314
141
|
];
|
|
142
|
+
const renderEditorHint = (isVisible) => (React.createElement(Box, { height: 1 }, isVisible && React.createElement(Text, { color: "yellow" }, " Press 'e' to open external editor.")));
|
|
315
143
|
return (React.createElement(Box, { flexDirection: "column", width: "50%", marginLeft: 1 },
|
|
316
|
-
React.createElement(Box, { flexDirection: "column", height: requestBoxHeight, borderStyle: "single", borderColor: activePanel === 'request' ? 'green' : 'gray', paddingX:
|
|
317
|
-
React.createElement(Box, { justifyContent: "space-between", height: 1 },
|
|
318
|
-
React.createElement(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
React.createElement(
|
|
324
|
-
|
|
325
|
-
React.createElement(Box, { flexDirection: "column", height: responseBoxHeight, borderStyle: "single", borderColor: activePanel === 'response' ? 'green' : 'gray', paddingX:
|
|
326
|
-
React.createElement(Box, { justifyContent: "space-between", height: 1 },
|
|
327
|
-
React.createElement(
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
React.createElement(Box, { flexGrow: 1, flexDirection: "column", overflow: "hidden" }, renderResponseContent()))));
|
|
144
|
+
React.createElement(Box, { flexDirection: "column", height: requestBoxHeight, borderStyle: "single", borderColor: activePanel === 'request' ? 'green' : 'gray', paddingX: 0 },
|
|
145
|
+
React.createElement(Box, { justifyContent: "space-between", height: 1, marginBottom: 0 },
|
|
146
|
+
React.createElement(Box, null,
|
|
147
|
+
React.createElement(Text, { color: activePanel === 'request' && subFocus === 'TITLE' ? 'yellow' : 'white', bold: activePanel === 'request' && subFocus === 'TITLE' },
|
|
148
|
+
activePanel === 'request' && subFocus === 'TITLE' ? '>' : ' ',
|
|
149
|
+
"Request"))),
|
|
150
|
+
React.createElement(TabHeader, { tabs: reqTabs, active: activeRequestTab, isFocused: activePanel === 'request' && subFocus === 'TABS', shortcutPrefix: "rq", commandMode: commandMode }),
|
|
151
|
+
React.createElement(Box, { flexDirection: "column", paddingX: 0, marginTop: 0, flexGrow: 1 }, renderRequestContent()),
|
|
152
|
+
renderEditorHint(activePanel === 'request' && subFocus === 'TABS')),
|
|
153
|
+
React.createElement(Box, { flexDirection: "column", height: responseBoxHeight, borderStyle: "single", borderColor: activePanel === 'response' ? 'green' : 'gray', paddingX: 0 },
|
|
154
|
+
React.createElement(Box, { justifyContent: "space-between", height: 1, marginBottom: 0 },
|
|
155
|
+
React.createElement(Box, null,
|
|
156
|
+
React.createElement(Text, { color: activePanel === 'response' && subFocus === 'TITLE' ? 'yellow' : 'white', bold: activePanel === 'response' && subFocus === 'TITLE' },
|
|
157
|
+
activePanel === 'response' && subFocus === 'TITLE' ? '>' : ' ',
|
|
158
|
+
"Response")),
|
|
159
|
+
React.createElement(Box, { width: 30, flexDirection: "row", justifyContent: "flex-end" }, response ? (React.createElement(Text, { color: response.status < 300 ? 'green' : 'red' },
|
|
160
|
+
response.status,
|
|
161
|
+
" ",
|
|
162
|
+
response.statusText)) : (React.createElement(Text, null, " ")))),
|
|
163
|
+
React.createElement(TabHeader, { tabs: resTabs, active: activeResponseTab, isFocused: activePanel === 'response' && subFocus === 'TABS', shortcutPrefix: "rs", commandMode: commandMode }),
|
|
164
|
+
React.createElement(Box, { flexDirection: "column", paddingX: 0, marginTop: 0, flexGrow: 1 }, renderResponseContent()),
|
|
165
|
+
renderEditorHint(activePanel === 'response' && subFocus === 'TABS'))));
|
|
339
166
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
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
7
|
import { setMode, listSelect, listSetMethodFilter, listSetTag, listSetFocus, listSelectTag, listSelectMethod, listSetSearch, listSetTagSearch, } from '../../store/appSlice.js';
|
|
8
8
|
import { navigateUp, navigateDown, navigateLeft, navigateRight, } from '../../store/navigationActions.js';
|
|
@@ -266,7 +266,13 @@ export default function ListView() {
|
|
|
266
266
|
React.createElement(Box, { flexDirection: "row", flexGrow: 1, overflow: "hidden" },
|
|
267
267
|
React.createElement(Box, null,
|
|
268
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)) })),
|
|
269
|
-
React.createElement(Box, { flexDirection: "column", flexGrow: 1, marginLeft: 1, borderStyle: "single", borderColor: list.focus === 'LIST' || list.focus === 'METHODS'
|
|
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")),
|
|
270
276
|
React.createElement(Box, { flexDirection: "row", alignItems: "center", gap: 2 },
|
|
271
277
|
React.createElement(MethodsBar, { activeMethod: list.activeMethod, commandMode: mode === 'COMMAND', isFocused: list.focus === 'METHODS', focusedIndex: list.focusedMethodIndex, onMethodChange: (method) => dispatch(listSetMethodFilter(method)) })),
|
|
272
278
|
React.createElement(SearchBar, { query: list.searchQuery, onQueryChange: (query) => dispatch(listSetSearch(query)), isSearchMode: mode === 'SEARCH' }),
|
package/dist/store/appSlice.d.ts
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { AppState, AppMode, Screen, ApiResponse } from '../types/app-state.js';
|
|
5
5
|
import { Endpoint, HttpMethod, OpenAPIDocument } from '../types/openapi.js';
|
|
6
|
-
export declare const setDocument: import("@reduxjs/toolkit").ActionCreatorWithPayload<OpenAPIDocument, "app/setDocument">, setEndpoints: import("@reduxjs/toolkit").ActionCreatorWithPayload<Endpoint[], "app/setEndpoints">, setScreen: import("@reduxjs/toolkit").ActionCreatorWithPayload<Screen, "app/setScreen">, setMode: import("@reduxjs/toolkit").ActionCreatorWithPayload<AppMode, "app/setMode">, setCommandInput: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/setCommandInput">, setNavigationCount: import("@reduxjs/toolkit").ActionCreatorWithPayload<number | null, "app/setNavigationCount">, setError: import("@reduxjs/toolkit").ActionCreatorWithPayload<string | null, "app/setError">, setLoading: import("@reduxjs/toolkit").ActionCreatorWithPayload<boolean, "app/setLoading">, listSelect: import("@reduxjs/toolkit").ActionCreatorWithPayload<number, "app/listSelect">, listSetMethodFilter: import("@reduxjs/toolkit").ActionCreatorWithPayload<HttpMethod | "ALL", "app/listSetMethodFilter">, listSetTag: import("@reduxjs/toolkit").ActionCreatorWithPayload<string | null, "app/listSetTag">, listSetFocus: import("@reduxjs/toolkit").ActionCreatorWithPayload<"LIST" | "TAGS" | "METHODS", "app/listSetFocus">, listSelectTag: import("@reduxjs/toolkit").ActionCreatorWithPayload<number, "app/listSelectTag">, listSelectMethod: import("@reduxjs/toolkit").ActionCreatorWithPayload<number, "app/listSelectMethod">, listSetSearch: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/listSetSearch">, listSetTagSearch: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/listSetTagSearch">, listScroll: import("@reduxjs/toolkit").ActionCreatorWithPayload<number, "app/listScroll">, detailSet: import("@reduxjs/toolkit").ActionCreatorWithPayload<Endpoint, "app/detailSet">, detailSetFocus: import("@reduxjs/toolkit").ActionCreatorWithPayload<number, "app/detailSetFocus">, detailSetParam: import("@reduxjs/toolkit").ActionCreatorWithPayload<{
|
|
6
|
+
export declare const setDocument: import("@reduxjs/toolkit").ActionCreatorWithPayload<OpenAPIDocument, "app/setDocument">, setEndpoints: import("@reduxjs/toolkit").ActionCreatorWithPayload<Endpoint[], "app/setEndpoints">, setScreen: import("@reduxjs/toolkit").ActionCreatorWithPayload<Screen, "app/setScreen">, setMode: import("@reduxjs/toolkit").ActionCreatorWithPayload<AppMode, "app/setMode">, setCommandInput: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/setCommandInput">, setNavigationCount: import("@reduxjs/toolkit").ActionCreatorWithPayload<number | null, "app/setNavigationCount">, setError: import("@reduxjs/toolkit").ActionCreatorWithPayload<string | null, "app/setError">, setLoading: import("@reduxjs/toolkit").ActionCreatorWithPayload<boolean, "app/setLoading">, listSelect: import("@reduxjs/toolkit").ActionCreatorWithPayload<number, "app/listSelect">, listSetMethodFilter: import("@reduxjs/toolkit").ActionCreatorWithPayload<HttpMethod | "ALL", "app/listSetMethodFilter">, listSetTag: import("@reduxjs/toolkit").ActionCreatorWithPayload<string | null, "app/listSetTag">, listSetFocus: import("@reduxjs/toolkit").ActionCreatorWithPayload<"LIST" | "TAGS" | "METHODS" | "TITLE", "app/listSetFocus">, listSelectTag: import("@reduxjs/toolkit").ActionCreatorWithPayload<number, "app/listSelectTag">, listSelectMethod: import("@reduxjs/toolkit").ActionCreatorWithPayload<number, "app/listSelectMethod">, listSetSearch: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/listSetSearch">, listSetTagSearch: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/listSetTagSearch">, listScroll: import("@reduxjs/toolkit").ActionCreatorWithPayload<number, "app/listScroll">, detailSet: import("@reduxjs/toolkit").ActionCreatorWithPayload<Endpoint, "app/detailSet">, detailSetFocus: import("@reduxjs/toolkit").ActionCreatorWithPayload<number, "app/detailSetFocus">, detailSetParam: import("@reduxjs/toolkit").ActionCreatorWithPayload<{
|
|
7
7
|
key: string;
|
|
8
8
|
value: string;
|
|
9
9
|
}, "app/detailSetParam">, detailSetBody: import("@reduxjs/toolkit").ActionCreatorWithPayload<string, "app/detailSetBody">, detailSetHeader: import("@reduxjs/toolkit").ActionCreatorWithPayload<{
|
|
10
10
|
key: string;
|
|
11
11
|
value: string;
|
|
12
|
-
}, "app/detailSetHeader">, detailSetPanel: import("@reduxjs/toolkit").ActionCreatorWithPayload<"left" | "request" | "response", "app/detailSetPanel">, detailSetRequestTab: import("@reduxjs/toolkit").ActionCreatorWithPayload<"headers" | "query" | "body", "app/detailSetRequestTab">, detailSetResponseTab: import("@reduxjs/toolkit").ActionCreatorWithPayload<"headers" | "body" | "cookies" | "curl", "app/detailSetResponseTab">, detailSetResponse: import("@reduxjs/toolkit").ActionCreatorWithPayload<ApiResponse | null, "app/detailSetResponse">, reset: import("@reduxjs/toolkit").ActionCreatorWithoutPayload<"app/reset">;
|
|
12
|
+
}, "app/detailSetHeader">, detailSetPanel: import("@reduxjs/toolkit").ActionCreatorWithPayload<"left" | "request" | "response", "app/detailSetPanel">, detailSetRequestTab: import("@reduxjs/toolkit").ActionCreatorWithPayload<"headers" | "query" | "body", "app/detailSetRequestTab">, detailSetResponseTab: import("@reduxjs/toolkit").ActionCreatorWithPayload<"headers" | "body" | "cookies" | "curl", "app/detailSetResponseTab">, detailSetSubFocus: import("@reduxjs/toolkit").ActionCreatorWithPayload<"TITLE" | "TABS", "app/detailSetSubFocus">, detailSetResponse: import("@reduxjs/toolkit").ActionCreatorWithPayload<ApiResponse | null, "app/detailSetResponse">, reset: import("@reduxjs/toolkit").ActionCreatorWithoutPayload<"app/reset">;
|
|
13
13
|
declare const _default: import("redux").Reducer<AppState>;
|
|
14
14
|
export default _default;
|
package/dist/store/appSlice.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* 애플리케이션 Redux Slice
|
|
3
3
|
*/
|
|
4
4
|
import { createSlice } from '@reduxjs/toolkit';
|
|
5
|
+
import { scaffoldSchema } from '../utils/schema-scaffolder.js';
|
|
5
6
|
// 초기 상태
|
|
6
7
|
const initialState = {
|
|
7
8
|
document: null,
|
|
@@ -122,16 +123,33 @@ const appSlice = createSlice({
|
|
|
122
123
|
},
|
|
123
124
|
detailSet: (state, action) => {
|
|
124
125
|
const endpoint = action.payload;
|
|
126
|
+
let initialRequestBody = '';
|
|
127
|
+
if (state.document) {
|
|
128
|
+
// 1. requestBody (OpenAPI 3.0)
|
|
129
|
+
if (endpoint.requestBody?.schema) {
|
|
130
|
+
const scaffolded = scaffoldSchema(endpoint.requestBody.schema, state.document);
|
|
131
|
+
initialRequestBody = JSON.stringify(scaffolded, null, 2);
|
|
132
|
+
}
|
|
133
|
+
// 2. in: body parameter (Swagger 2.0)
|
|
134
|
+
else {
|
|
135
|
+
const bodyParam = endpoint.parameters.find((p) => p.in === 'body');
|
|
136
|
+
if (bodyParam && bodyParam.schema) {
|
|
137
|
+
const scaffolded = scaffoldSchema(bodyParam.schema, state.document);
|
|
138
|
+
initialRequestBody = JSON.stringify(scaffolded, null, 2);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
125
142
|
state.screen = 'DETAIL';
|
|
126
143
|
state.detail = {
|
|
127
144
|
endpoint,
|
|
128
145
|
focusedFieldIndex: 0,
|
|
129
146
|
parameterValues: {},
|
|
130
|
-
requestBody:
|
|
147
|
+
requestBody: initialRequestBody,
|
|
131
148
|
headers: {},
|
|
132
149
|
activePanel: 'left',
|
|
133
150
|
activeRequestTab: 'headers',
|
|
134
151
|
activeResponseTab: 'body',
|
|
152
|
+
subFocus: 'TABS',
|
|
135
153
|
response: null,
|
|
136
154
|
};
|
|
137
155
|
},
|
|
@@ -170,6 +188,11 @@ const appSlice = createSlice({
|
|
|
170
188
|
state.detail.activeResponseTab = action.payload;
|
|
171
189
|
}
|
|
172
190
|
},
|
|
191
|
+
detailSetSubFocus: (state, action) => {
|
|
192
|
+
if (state.detail) {
|
|
193
|
+
state.detail.subFocus = action.payload;
|
|
194
|
+
}
|
|
195
|
+
},
|
|
173
196
|
detailSetResponse: (state, action) => {
|
|
174
197
|
if (state.detail) {
|
|
175
198
|
state.detail.response = action.payload;
|
|
@@ -178,5 +201,5 @@ const appSlice = createSlice({
|
|
|
178
201
|
reset: () => initialState,
|
|
179
202
|
},
|
|
180
203
|
});
|
|
181
|
-
export const { setDocument, setEndpoints, setScreen, setMode, setCommandInput, setNavigationCount, setError, setLoading, listSelect, listSetMethodFilter, listSetTag, listSetFocus, listSelectTag, listSelectMethod, listSetSearch, listSetTagSearch, listScroll, detailSet, detailSetFocus, detailSetParam, detailSetBody, detailSetHeader, detailSetPanel, detailSetRequestTab, detailSetResponseTab, detailSetResponse, reset, } = appSlice.actions;
|
|
204
|
+
export const { setDocument, setEndpoints, setScreen, setMode, setCommandInput, setNavigationCount, setError, setLoading, listSelect, listSetMethodFilter, listSetTag, listSetFocus, listSelectTag, listSelectMethod, listSetSearch, listSetTagSearch, listScroll, detailSet, detailSetFocus, detailSetParam, detailSetBody, detailSetHeader, detailSetPanel, detailSetRequestTab, detailSetResponseTab, detailSetSubFocus, detailSetResponse, reset, } = appSlice.actions;
|
|
182
205
|
export default appSlice.reducer;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { listSetFocus, listSelectMethod, listSetMethodFilter, listSelect, listSelectTag, detailSetFocus, detailSetPanel, } from './appSlice.js';
|
|
1
|
+
import { listSetFocus, listSelectMethod, listSetMethodFilter, listSelect, listSelectTag, detailSetFocus, detailSetPanel, detailSetSubFocus, detailSetRequestTab, detailSetResponseTab, } from './appSlice.js';
|
|
2
2
|
const METHODS = ['ALL', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
3
|
+
const REQUEST_TABS = ['headers', 'body', 'query'];
|
|
4
|
+
const RESPONSE_TABS = ['body', 'headers', 'cookies', 'curl'];
|
|
3
5
|
// k Key (Up)
|
|
4
6
|
export const navigateUp = () => (dispatch, getState) => {
|
|
5
7
|
const { app } = getState();
|
|
@@ -9,8 +11,11 @@ export const navigateUp = () => (dispatch, getState) => {
|
|
|
9
11
|
const prevIndex = Math.max(list.selectedTagIndex - 1, 0);
|
|
10
12
|
dispatch(listSelectTag(prevIndex));
|
|
11
13
|
}
|
|
14
|
+
else if (list.focus === 'TITLE') {
|
|
15
|
+
// No action
|
|
16
|
+
}
|
|
12
17
|
else if (list.focus === 'METHODS') {
|
|
13
|
-
|
|
18
|
+
dispatch(listSetFocus('TITLE'));
|
|
14
19
|
}
|
|
15
20
|
else {
|
|
16
21
|
// LIST
|
|
@@ -28,7 +33,20 @@ export const navigateUp = () => (dispatch, getState) => {
|
|
|
28
33
|
const prevIndex = Math.max(detail.focusedFieldIndex - 1, 0);
|
|
29
34
|
dispatch(detailSetFocus(prevIndex));
|
|
30
35
|
}
|
|
31
|
-
|
|
36
|
+
else if (detail.activePanel === 'request') {
|
|
37
|
+
if (detail.subFocus === 'TABS') {
|
|
38
|
+
dispatch(detailSetSubFocus('TITLE'));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else if (detail.activePanel === 'response') {
|
|
42
|
+
if (detail.subFocus === 'TITLE') {
|
|
43
|
+
dispatch(detailSetPanel('request'));
|
|
44
|
+
dispatch(detailSetSubFocus('TABS'));
|
|
45
|
+
}
|
|
46
|
+
else if (detail.subFocus === 'TABS') {
|
|
47
|
+
dispatch(detailSetSubFocus('TITLE'));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
32
50
|
}
|
|
33
51
|
};
|
|
34
52
|
// j Key (Down)
|
|
@@ -37,14 +55,20 @@ export const navigateDown = () => (dispatch, getState) => {
|
|
|
37
55
|
const { screen, list, detail, endpoints } = app;
|
|
38
56
|
if (screen === 'LIST') {
|
|
39
57
|
if (list.focus === 'TAGS') {
|
|
40
|
-
// 태그 목록 계산
|
|
41
|
-
const
|
|
42
|
-
endpoints.forEach((ep) => ep.tags?.forEach((tag) =>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
// 태그 목록 계산
|
|
59
|
+
const uniqueTags = new Set();
|
|
60
|
+
endpoints.forEach((ep) => ep.tags?.forEach((tag) => uniqueTags.add(tag)));
|
|
61
|
+
const sortedTags = Array.from(uniqueTags).sort();
|
|
62
|
+
const allTags = ['Overview', ...sortedTags];
|
|
63
|
+
// 검색어 필터링 적용
|
|
64
|
+
const filteredTags = list.tagSearchQuery
|
|
65
|
+
? allTags.filter((tag) => tag.toLowerCase().includes(list.tagSearchQuery.toLowerCase()))
|
|
66
|
+
: allTags;
|
|
67
|
+
const nextIndex = Math.min(list.selectedTagIndex + 1, filteredTags.length - 1);
|
|
68
|
+
dispatch(listSelectTag(nextIndex));
|
|
69
|
+
}
|
|
70
|
+
else if (list.focus === 'TITLE') {
|
|
71
|
+
dispatch(listSetFocus('METHODS'));
|
|
48
72
|
}
|
|
49
73
|
else if (list.focus === 'METHODS') {
|
|
50
74
|
dispatch(listSetFocus('LIST'));
|
|
@@ -74,19 +98,36 @@ export const navigateDown = () => (dispatch, getState) => {
|
|
|
74
98
|
const nextIndex = Math.min(detail.focusedFieldIndex + 1, totalFields - 1);
|
|
75
99
|
dispatch(detailSetFocus(nextIndex));
|
|
76
100
|
}
|
|
101
|
+
else if (detail.activePanel === 'request') {
|
|
102
|
+
if (detail.subFocus === 'TITLE') {
|
|
103
|
+
dispatch(detailSetSubFocus('TABS'));
|
|
104
|
+
}
|
|
105
|
+
else if (detail.subFocus === 'TABS') {
|
|
106
|
+
dispatch(detailSetPanel('response'));
|
|
107
|
+
dispatch(detailSetSubFocus('TITLE'));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (detail.activePanel === 'response') {
|
|
111
|
+
if (detail.subFocus === 'TITLE') {
|
|
112
|
+
dispatch(detailSetSubFocus('TABS'));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
77
115
|
}
|
|
78
116
|
};
|
|
79
117
|
// h Key (Left)
|
|
80
118
|
export const navigateLeft = () => (dispatch, getState) => {
|
|
81
119
|
const { app } = getState();
|
|
82
|
-
const { screen, list } = app;
|
|
120
|
+
const { screen, list, detail } = app;
|
|
83
121
|
if (screen === 'LIST') {
|
|
84
122
|
if (list.focus === 'LIST') {
|
|
85
123
|
dispatch(listSetFocus('TAGS'));
|
|
86
124
|
}
|
|
125
|
+
else if (list.focus === 'TITLE') {
|
|
126
|
+
dispatch(listSetFocus('TAGS'));
|
|
127
|
+
}
|
|
87
128
|
else if (list.focus === 'METHODS') {
|
|
88
129
|
if (list.focusedMethodIndex === 0) {
|
|
89
|
-
dispatch(listSetFocus('
|
|
130
|
+
dispatch(listSetFocus('TITLE'));
|
|
90
131
|
}
|
|
91
132
|
else {
|
|
92
133
|
const prevIndex = Math.max(list.focusedMethodIndex - 1, 0);
|
|
@@ -95,8 +136,34 @@ export const navigateLeft = () => (dispatch, getState) => {
|
|
|
95
136
|
}
|
|
96
137
|
}
|
|
97
138
|
}
|
|
98
|
-
else if (screen === 'DETAIL') {
|
|
99
|
-
|
|
139
|
+
else if (screen === 'DETAIL' && detail) {
|
|
140
|
+
if (detail.activePanel === 'left') {
|
|
141
|
+
// Already at left, do nothing or go back to list (handled by u key usually)
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
if (detail.subFocus === 'TITLE') {
|
|
145
|
+
dispatch(detailSetPanel('left'));
|
|
146
|
+
}
|
|
147
|
+
else if (detail.subFocus === 'TABS') {
|
|
148
|
+
const tabs = detail.activePanel === 'request' ? REQUEST_TABS : RESPONSE_TABS;
|
|
149
|
+
const currentTab = detail.activePanel === 'request'
|
|
150
|
+
? detail.activeRequestTab
|
|
151
|
+
: detail.activeResponseTab;
|
|
152
|
+
const index = tabs.indexOf(currentTab);
|
|
153
|
+
if (index === 0) {
|
|
154
|
+
dispatch(detailSetSubFocus('TITLE'));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const prevTab = tabs[index - 1];
|
|
158
|
+
if (detail.activePanel === 'request') {
|
|
159
|
+
dispatch(detailSetRequestTab(prevTab));
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
dispatch(detailSetResponseTab(prevTab));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
100
167
|
}
|
|
101
168
|
};
|
|
102
169
|
// l Key (Right)
|
|
@@ -109,6 +176,9 @@ export const navigateRight = () => (dispatch, getState) => {
|
|
|
109
176
|
dispatch(listSelectTag(list.selectedTagIndex)); // 현재 태그 선택 유지하며 포커스만 이동
|
|
110
177
|
dispatch(listSetFocus('LIST'));
|
|
111
178
|
}
|
|
179
|
+
else if (list.focus === 'TITLE') {
|
|
180
|
+
dispatch(listSetFocus('METHODS'));
|
|
181
|
+
}
|
|
112
182
|
else if (list.focus === 'LIST') {
|
|
113
183
|
const nextIndex = (list.focusedMethodIndex + 1) % METHODS.length;
|
|
114
184
|
dispatch(listSetFocus('METHODS'));
|
|
@@ -124,6 +194,28 @@ export const navigateRight = () => (dispatch, getState) => {
|
|
|
124
194
|
else if (screen === 'DETAIL' && detail) {
|
|
125
195
|
if (detail.activePanel === 'left') {
|
|
126
196
|
dispatch(detailSetPanel('request'));
|
|
197
|
+
dispatch(detailSetSubFocus('TITLE'));
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
if (detail.subFocus === 'TITLE') {
|
|
201
|
+
dispatch(detailSetSubFocus('TABS'));
|
|
202
|
+
}
|
|
203
|
+
else if (detail.subFocus === 'TABS') {
|
|
204
|
+
const tabs = detail.activePanel === 'request' ? REQUEST_TABS : RESPONSE_TABS;
|
|
205
|
+
const currentTab = detail.activePanel === 'request'
|
|
206
|
+
? detail.activeRequestTab
|
|
207
|
+
: detail.activeResponseTab;
|
|
208
|
+
const index = tabs.indexOf(currentTab);
|
|
209
|
+
const nextIndex = (index + 1) % tabs.length;
|
|
210
|
+
const nextTab = tabs[nextIndex];
|
|
211
|
+
if (detail.activePanel === 'request') {
|
|
212
|
+
dispatch(detailSetRequestTab(nextTab));
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
dispatch(detailSetResponseTab(nextTab));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// CONTENT: Do nothing or handle specific logic
|
|
127
219
|
}
|
|
128
220
|
}
|
|
129
221
|
};
|
|
@@ -21,7 +21,7 @@ export interface ListViewState {
|
|
|
21
21
|
filteredEndpoints: Endpoint[];
|
|
22
22
|
activeMethod: HttpMethod | 'ALL';
|
|
23
23
|
activeTag: string | null;
|
|
24
|
-
focus: 'TAGS' | 'LIST' | 'METHODS';
|
|
24
|
+
focus: 'TAGS' | 'LIST' | 'METHODS' | 'TITLE';
|
|
25
25
|
selectedTagIndex: number;
|
|
26
26
|
focusedMethodIndex: number;
|
|
27
27
|
searchQuery: string;
|
|
@@ -41,6 +41,7 @@ export interface DetailViewState {
|
|
|
41
41
|
activePanel: 'left' | 'request' | 'response';
|
|
42
42
|
activeRequestTab: 'headers' | 'body' | 'query';
|
|
43
43
|
activeResponseTab: 'body' | 'headers' | 'cookies' | 'curl';
|
|
44
|
+
subFocus: 'TITLE' | 'TABS';
|
|
44
45
|
response: ApiResponse | null;
|
|
45
46
|
}
|
|
46
47
|
export interface ApiResponse {
|
|
@@ -81,7 +82,7 @@ export type AppAction = {
|
|
|
81
82
|
payload: string | null;
|
|
82
83
|
} | {
|
|
83
84
|
type: 'LIST_SET_FOCUS';
|
|
84
|
-
payload: 'TAGS' | 'LIST' | 'METHODS';
|
|
85
|
+
payload: 'TAGS' | 'LIST' | 'METHODS' | 'TITLE';
|
|
85
86
|
} | {
|
|
86
87
|
type: 'LIST_SELECT_TAG';
|
|
87
88
|
payload: number;
|
|
@@ -127,6 +128,9 @@ export type AppAction = {
|
|
|
127
128
|
} | {
|
|
128
129
|
type: 'DETAIL_SET_RESPONSE_TAB';
|
|
129
130
|
payload: 'body' | 'headers' | 'cookies' | 'curl';
|
|
131
|
+
} | {
|
|
132
|
+
type: 'DETAIL_SET_SUB_FOCUS';
|
|
133
|
+
payload: 'TITLE' | 'TABS';
|
|
130
134
|
} | {
|
|
131
135
|
type: 'DETAIL_SET_RESPONSE';
|
|
132
136
|
payload: ApiResponse | null;
|