@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 CHANGED
@@ -1,16 +1,15 @@
1
1
  # lazyapi
2
2
 
3
- ![demo](https://github.com/user-attachments/assets/ed95f068-09da-4226-91fd-430027258b31)
4
-
5
- **⚠️ Work in Progress**
6
-
7
3
  <div align="center">
8
4
 
9
- A **Vim-style TUI (Terminal User Interface)** tool for exploring Swagger/OpenAPI documents and testing APIs in the terminal
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
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
12
9
  [![Node Version](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen)](https://nodejs.org)
13
10
 
11
+ ![demo](https://github.com/user-attachments/assets/ed95f068-09da-4226-91fd-430027258b31)
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 (coming soon)
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, rqb1, rs1, rq1, rb1...)
67
- const match = cmd.match(/^(pp|qp|h|rqb|rs|rq|rb)(\d+)$/);
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 === 'rqb') {
104
- // Request Body (기존 rb -> rqb)
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 === 'rb') {
121
- // Responses (사용자 요청: rb)
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, document: document, parameterValues: detail.parameterValues, requestBody: detail.requestBody, requestHeaders: detail.headers, baseURL: baseURL, mode: mode, isLoading: loading, commandMode: mode === 'COMMAND' })),
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 = 'object';
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 rbIndex = item.globalIndex - bodyStartIndex + 1;
561
- commandHint = `rb${rbIndex}`;
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 }, value || (isFieldFocused ? ' (empty) ' : '(empty)'))))),
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 ? 'rqb1' : '';
643
+ const commandHint = commandMode ? 'bt1' : '';
629
644
  return (React.createElement(Box, { key: key, flexDirection: "column" },
630
- React.createElement(Box, { flexDirection: "row", backgroundColor: isFieldFocused ? 'green' : undefined, marginLeft: 1 },
631
- commandMode && (React.createElement(Box, { width: 8 },
632
- React.createElement(Text, { color: "yellow" },
633
- "[",
634
- commandHint,
635
- "]"))),
636
- React.createElement(Text, { color: isFieldFocused ? 'black' : 'gray' }, item.endpoint.requestBody?.description || 'Request Body Content')),
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 rbIndex = item.globalIndex - responsesStartIndex + 1;
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
- `rb${rbIndex}`,
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, AppMode } from '../../types/app-state.js';
6
- import { Endpoint, OpenAPIDocument } from '../../types/openapi.js';
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, document, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode, }: RightPanelProps): React.JSX.Element;
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, { useState, useEffect, useMemo } from 'react';
5
- import { Box, Text, useStdout, useStdin } from 'ink';
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, { bold: t.id === active, color: t.id === active ? 'black' : 'gray', backgroundColor: t.id === active ? 'green' : undefined }, ' ' + t.label + ' ')));
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, document, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode = false, }) {
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
- const contentHeight = Math.max(requestBoxHeight - 4, 1);
43
- // -- Request Box State --
44
- const [reqScroll, setReqScroll] = useState(0);
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: openExternalEditor,
144
- }, activePanel === 'request' && activeRequestTab === 'body');
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
- // Response Content Lines 계산 (메모이제이션)
178
- const resTotalLines = useMemo(() => {
179
- if (activeResponseTab === 'curl') {
180
- return curlCommand.split('\n').length;
181
- }
182
- if (!response)
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
- return renderKeyValue(requestHeaders, reqScroll, contentHeight);
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
- let content = null;
245
- if (!requestBody) {
246
- content = React.createElement(Text, { color: "gray" }, "No body");
247
- }
248
- else {
249
- const lines = requestBody.split('\n');
250
- content = renderLines(lines, reqScroll, contentHeight);
251
- }
252
- // Body 탭이고 Request 패널 활성 상태면 항상 안내 문구 표시
253
- if (activePanel === 'request') {
254
- return (React.createElement(Box, { flexDirection: "column" },
255
- content,
256
- React.createElement(Box, { borderStyle: "single", borderColor: "yellow", marginTop: 0 },
257
- React.createElement(Text, { color: "yellow" }, "Press 'e' key to open external editor."))));
258
- }
259
- return content;
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
- const visible = queries.slice(reqScroll, reqScroll + contentHeight);
266
- return visible.map((q, i) => (React.createElement(Box, { key: reqScroll + i },
267
- React.createElement(Text, { color: "blue" },
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'), resScroll, contentHeight);
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'), resScroll, contentHeight);
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
- return renderKeyValue(response.headers, resScroll, contentHeight);
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
- return renderKeyValue(response.cookies, resScroll, contentHeight);
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: 1 },
317
- React.createElement(Box, { justifyContent: "space-between", height: 1 },
318
- React.createElement(Text, { bold: true, color: activePanel === 'request' ? 'green' : 'white' }, "Request"),
319
- React.createElement(Box, { width: 15 },
320
- React.createElement(Text, { color: "gray" }, reqTotalLines > contentHeight
321
- ? `${reqScroll + 1}-${Math.min(reqScroll + contentHeight, reqTotalLines)}/${reqTotalLines}`
322
- : ' '))),
323
- React.createElement(TabHeader, { tabs: reqTabs, active: activeRequestTab, isFocused: activePanel === 'request', shortcutPrefix: "rq", commandMode: commandMode }),
324
- React.createElement(Box, { flexGrow: 1, flexDirection: "column", overflow: "hidden" }, renderRequestContent())),
325
- React.createElement(Box, { flexDirection: "column", height: responseBoxHeight, borderStyle: "single", borderColor: activePanel === 'response' ? 'green' : 'gray', paddingX: 1 },
326
- React.createElement(Box, { justifyContent: "space-between", height: 1 },
327
- React.createElement(Text, { bold: true, color: activePanel === 'response' ? 'green' : 'white' }, "Response"),
328
- React.createElement(Box, { width: 30, flexDirection: "row", justifyContent: "flex-end" },
329
- response ? (React.createElement(Text, { color: response.status < 300 ? 'green' : 'red' },
330
- response.status,
331
- " ",
332
- response.statusText)) : (React.createElement(Text, null, " ")),
333
- React.createElement(Box, { width: 15 },
334
- React.createElement(Text, { color: "gray" }, resTotalLines > contentHeight
335
- ? `${resScroll + 1}-${Math.min(resScroll + contentHeight, resTotalLines)}/${resTotalLines}`
336
- : ' ')))),
337
- React.createElement(TabHeader, { tabs: resTabs, active: activeResponseTab, isFocused: activePanel === 'response', shortcutPrefix: "rs", commandMode: commandMode }),
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' ? 'green' : 'gray', paddingX: 1, paddingBottom: 1, paddingTop: 0 },
269
+ React.createElement(Box, { flexDirection: "column", flexGrow: 1, marginLeft: 1, borderStyle: "single", borderColor: list.focus === 'LIST' || list.focus === 'METHODS' || list.focus === 'TITLE'
270
+ ? 'green'
271
+ : 'gray', paddingX: 1, paddingBottom: 1, paddingTop: 0 },
272
+ React.createElement(Box, { marginBottom: 0 },
273
+ React.createElement(Text, { color: list.focus === 'TITLE' ? 'yellow' : 'white', bold: list.focus === 'TITLE' },
274
+ list.focus === 'TITLE' ? '>' : ' ',
275
+ "Endpoints")),
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' }),
@@ -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;
@@ -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
- // RightPanel 스크롤은 컴포넌트 Functional Update로 처리되므로 둠
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
- // 태그 목록 계산 (Explorer와 동일한 로직 필요)
41
- const tags = new Set();
42
- endpoints.forEach((ep) => ep.tags?.forEach((tag) => tags.add(tag)));
43
- // Tag Search Query가 있을 경우 필터링된 개수 기준이어야 함
44
- // 현재는 간단히 최대치로 제한 (ListView에서 계산된 filteredTags.length가 이상적이나 접근 불가)
45
- // Thunk에서 filteredTags를 직접 계산하거나 상위에서 넘겨줘야 함.
46
- // 일단 selectedTagIndex 업데이트만 처리.
47
- dispatch(listSelectTag(list.selectedTagIndex + 1));
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('LIST'));
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
- dispatch(detailSetPanel('left'));
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gomjellie/lazyapi",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "A terminal-based Swagger/OpenAPI explorer with Vim-style keyboard navigation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",