@gomjellie/lazyapi 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -261,6 +261,6 @@ export default function DetailView() {
261
261
  dispatch(detailSetPanel('request'));
262
262
  dispatch(detailSetRequestTab('body'));
263
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' })),
264
+ React.createElement(RightPanel, { activeRequestTab: activeRequestTab, activeResponseTab: activeResponseTab, activePanel: activePanel, subFocus: detail.subFocus, 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' })),
265
265
  React.createElement(KeybindingFooter, { bindings: keyBindings, mode: mode, commandInput: commandInput, onCommandChange: (value) => dispatch(setCommandInput(value)), onCommandSubmit: handleCommandSubmit })));
266
266
  }
@@ -8,6 +8,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' | 'CONTENT';
11
12
  response: ApiResponse | null;
12
13
  endpoint: Endpoint;
13
14
  document?: OpenAPIDocument;
@@ -19,5 +20,5 @@ interface RightPanelProps {
19
20
  isLoading: boolean;
20
21
  commandMode?: boolean;
21
22
  }
22
- export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, response, endpoint, document, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode, }: RightPanelProps): React.JSX.Element;
23
+ export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, subFocus, response, endpoint, document, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode, }: RightPanelProps): React.JSX.Element;
23
24
  export {};
@@ -5,7 +5,7 @@ import React, { useState, useEffect, useMemo } from 'react';
5
5
  import { Box, Text, useStdout, useStdin } from 'ink';
6
6
  import { useKeyPress } from '../../hooks/useKeyPress.js';
7
7
  import { useAppDispatch } from '../../store/hooks.js';
8
- import { detailSetPanel, detailSetBody, setMode } from '../../store/appSlice.js';
8
+ import { detailSetPanel, detailSetBody, detailSetSubFocus, setMode } from '../../store/appSlice.js';
9
9
  import { spawnSync } from 'child_process';
10
10
  import fs from 'fs';
11
11
  import os from 'os';
@@ -27,11 +27,14 @@ const TabHeader = ({ tabs, active, isFocused, shortcutPrefix, commandMode }) =>
27
27
  showHint = true;
28
28
  hintText = `[${index + 1}] `;
29
29
  }
30
+ const isActive = t.id === active;
31
+ const showIndicator = isFocused && isActive;
30
32
  return (React.createElement(Box, { key: t.id },
31
33
  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 + ' ')));
34
+ React.createElement(Text, { color: "yellow" }, showIndicator ? '>' : ' '),
35
+ React.createElement(Text, { bold: isActive, underline: showIndicator, color: isActive ? 'black' : 'gray', backgroundColor: isActive ? 'green' : undefined }, t.label + ' ')));
33
36
  })));
34
- export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, response, endpoint, document, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode = false, }) {
37
+ export default function RightPanel({ activeRequestTab, activeResponseTab, activePanel, subFocus, response, endpoint, document, parameterValues, requestBody, requestHeaders, baseURL, mode, isLoading, commandMode = false, }) {
35
38
  const { stdout } = useStdout();
36
39
  const { isRawModeSupported, setRawMode } = useStdin();
37
40
  const dispatch = useAppDispatch();
@@ -92,14 +95,20 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
92
95
  else {
93
96
  // 스크롤이 끝에 도달하면 Response 패널로 이동
94
97
  dispatch(detailSetPanel('response'));
98
+ dispatch(detailSetSubFocus('TITLE'));
95
99
  return prev;
96
100
  }
97
101
  });
98
102
  },
99
103
  k: () => {
100
- setReqScroll((prev) => Math.max(prev - 1, 0));
104
+ setReqScroll((prev) => {
105
+ if (prev > 0)
106
+ return prev - 1;
107
+ dispatch(detailSetSubFocus('TABS'));
108
+ return 0;
109
+ });
101
110
  },
102
- }, mode === 'NORMAL' && activePanel === 'request');
111
+ }, mode === 'NORMAL' && activePanel === 'request' && subFocus === 'CONTENT');
103
112
  const openExternalEditor = () => {
104
113
  try {
105
114
  const tempFile = path.join(os.tmpdir(), `lazyapi-body-${Date.now()}.json`);
@@ -206,17 +215,13 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
206
215
  },
207
216
  k: () => {
208
217
  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
- }
218
+ if (prev > 0)
219
+ return prev - 1;
220
+ dispatch(detailSetSubFocus('TABS'));
221
+ return 0;
217
222
  });
218
223
  },
219
- }, mode === 'NORMAL' && activePanel === 'response');
224
+ }, mode === 'NORMAL' && activePanel === 'response' && subFocus === 'CONTENT');
220
225
  // 렌더링 헬퍼
221
226
  const renderLines = (lines, offset, limit) => {
222
227
  const visible = lines.slice(offset, offset + limit);
@@ -314,17 +319,23 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
314
319
  ];
315
320
  return (React.createElement(Box, { flexDirection: "column", width: "50%", marginLeft: 1 },
316
321
  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"),
322
+ React.createElement(Box, { justifyContent: "space-between", height: 1, marginBottom: 0 },
323
+ React.createElement(Box, null,
324
+ React.createElement(Text, { color: activePanel === 'request' && subFocus === 'TITLE' ? 'yellow' : 'white', bold: activePanel === 'request' && subFocus === 'TITLE' },
325
+ activePanel === 'request' && subFocus === 'TITLE' ? '>' : ' ',
326
+ "Request")),
319
327
  React.createElement(Box, { width: 15 },
320
328
  React.createElement(Text, { color: "gray" }, reqTotalLines > contentHeight
321
329
  ? `${reqScroll + 1}-${Math.min(reqScroll + contentHeight, reqTotalLines)}/${reqTotalLines}`
322
330
  : ' '))),
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())),
331
+ React.createElement(TabHeader, { tabs: reqTabs, active: activeRequestTab, isFocused: activePanel === 'request' && subFocus === 'TABS', shortcutPrefix: "rq", commandMode: commandMode }),
332
+ React.createElement(Box, { flexGrow: 1, flexDirection: "column", overflow: "hidden", borderStyle: "single", borderColor: activePanel === 'request' && subFocus === 'CONTENT' ? 'green' : 'gray', paddingX: 1 }, renderRequestContent())),
325
333
  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"),
334
+ React.createElement(Box, { justifyContent: "space-between", height: 1, marginBottom: 0 },
335
+ React.createElement(Box, null,
336
+ React.createElement(Text, { color: activePanel === 'response' && subFocus === 'TITLE' ? 'yellow' : 'white', bold: activePanel === 'response' && subFocus === 'TITLE' },
337
+ activePanel === 'response' && subFocus === 'TITLE' ? '>' : ' ',
338
+ "Response")),
328
339
  React.createElement(Box, { width: 30, flexDirection: "row", justifyContent: "flex-end" },
329
340
  response ? (React.createElement(Text, { color: response.status < 300 ? 'green' : 'red' },
330
341
  response.status,
@@ -334,6 +345,6 @@ export default function RightPanel({ activeRequestTab, activeResponseTab, active
334
345
  React.createElement(Text, { color: "gray" }, resTotalLines > contentHeight
335
346
  ? `${resScroll + 1}-${Math.min(resScroll + contentHeight, resTotalLines)}/${resTotalLines}`
336
347
  : ' ')))),
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()))));
348
+ React.createElement(TabHeader, { tabs: resTabs, active: activeResponseTab, isFocused: activePanel === 'response' && subFocus === 'TABS', shortcutPrefix: "rs", commandMode: commandMode }),
349
+ React.createElement(Box, { flexGrow: 1, flexDirection: "column", overflow: "hidden", borderStyle: "single", borderColor: activePanel === 'response' && subFocus === 'CONTENT' ? 'green' : 'gray', paddingX: 1 }, renderResponseContent()))));
339
350
  }
@@ -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" | "CONTENT", "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;
@@ -132,6 +132,7 @@ const appSlice = createSlice({
132
132
  activePanel: 'left',
133
133
  activeRequestTab: 'headers',
134
134
  activeResponseTab: 'body',
135
+ subFocus: 'TABS',
135
136
  response: null,
136
137
  };
137
138
  },
@@ -170,6 +171,11 @@ const appSlice = createSlice({
170
171
  state.detail.activeResponseTab = action.payload;
171
172
  }
172
173
  },
174
+ detailSetSubFocus: (state, action) => {
175
+ if (state.detail) {
176
+ state.detail.subFocus = action.payload;
177
+ }
178
+ },
173
179
  detailSetResponse: (state, action) => {
174
180
  if (state.detail) {
175
181
  state.detail.response = action.payload;
@@ -178,5 +184,5 @@ const appSlice = createSlice({
178
184
  reset: () => initialState,
179
185
  },
180
186
  });
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;
187
+ 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
188
  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,22 @@ 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
+ // CONTENT: Handled by component (scroll)
41
+ }
42
+ else if (detail.activePanel === 'response') {
43
+ if (detail.subFocus === 'TITLE') {
44
+ dispatch(detailSetPanel('request'));
45
+ dispatch(detailSetSubFocus('CONTENT'));
46
+ }
47
+ else if (detail.subFocus === 'TABS') {
48
+ dispatch(detailSetSubFocus('TITLE'));
49
+ }
50
+ // CONTENT: Handled by component (scroll)
51
+ }
32
52
  }
33
53
  };
34
54
  // j Key (Down)
@@ -37,14 +57,20 @@ export const navigateDown = () => (dispatch, getState) => {
37
57
  const { screen, list, detail, endpoints } = app;
38
58
  if (screen === 'LIST') {
39
59
  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));
60
+ // 태그 목록 계산
61
+ const uniqueTags = new Set();
62
+ endpoints.forEach((ep) => ep.tags?.forEach((tag) => uniqueTags.add(tag)));
63
+ const sortedTags = Array.from(uniqueTags).sort();
64
+ const allTags = ['Overview', ...sortedTags];
65
+ // 검색어 필터링 적용
66
+ const filteredTags = list.tagSearchQuery
67
+ ? allTags.filter((tag) => tag.toLowerCase().includes(list.tagSearchQuery.toLowerCase()))
68
+ : allTags;
69
+ const nextIndex = Math.min(list.selectedTagIndex + 1, filteredTags.length - 1);
70
+ dispatch(listSelectTag(nextIndex));
71
+ }
72
+ else if (list.focus === 'TITLE') {
73
+ dispatch(listSetFocus('METHODS'));
48
74
  }
49
75
  else if (list.focus === 'METHODS') {
50
76
  dispatch(listSetFocus('LIST'));
@@ -74,19 +100,40 @@ export const navigateDown = () => (dispatch, getState) => {
74
100
  const nextIndex = Math.min(detail.focusedFieldIndex + 1, totalFields - 1);
75
101
  dispatch(detailSetFocus(nextIndex));
76
102
  }
103
+ else if (detail.activePanel === 'request') {
104
+ if (detail.subFocus === 'TITLE') {
105
+ dispatch(detailSetSubFocus('TABS'));
106
+ }
107
+ else if (detail.subFocus === 'TABS') {
108
+ dispatch(detailSetSubFocus('CONTENT'));
109
+ }
110
+ // CONTENT: Handled by component (scroll) -> Move to Response
111
+ }
112
+ else if (detail.activePanel === 'response') {
113
+ if (detail.subFocus === 'TITLE') {
114
+ dispatch(detailSetSubFocus('TABS'));
115
+ }
116
+ else if (detail.subFocus === 'TABS') {
117
+ dispatch(detailSetSubFocus('CONTENT'));
118
+ }
119
+ // CONTENT: Handled by component (scroll)
120
+ }
77
121
  }
78
122
  };
79
123
  // h Key (Left)
80
124
  export const navigateLeft = () => (dispatch, getState) => {
81
125
  const { app } = getState();
82
- const { screen, list } = app;
126
+ const { screen, list, detail } = app;
83
127
  if (screen === 'LIST') {
84
128
  if (list.focus === 'LIST') {
85
129
  dispatch(listSetFocus('TAGS'));
86
130
  }
131
+ else if (list.focus === 'TITLE') {
132
+ dispatch(listSetFocus('TAGS'));
133
+ }
87
134
  else if (list.focus === 'METHODS') {
88
135
  if (list.focusedMethodIndex === 0) {
89
- dispatch(listSetFocus('LIST'));
136
+ dispatch(listSetFocus('TITLE'));
90
137
  }
91
138
  else {
92
139
  const prevIndex = Math.max(list.focusedMethodIndex - 1, 0);
@@ -95,8 +142,34 @@ export const navigateLeft = () => (dispatch, getState) => {
95
142
  }
96
143
  }
97
144
  }
98
- else if (screen === 'DETAIL') {
99
- dispatch(detailSetPanel('left'));
145
+ else if (screen === 'DETAIL' && detail) {
146
+ if (detail.activePanel === 'left') {
147
+ // Already at left, do nothing or go back to list (handled by u key usually)
148
+ }
149
+ else {
150
+ if (detail.subFocus === 'TITLE' || detail.subFocus === 'CONTENT') {
151
+ dispatch(detailSetPanel('left'));
152
+ }
153
+ else if (detail.subFocus === 'TABS') {
154
+ const tabs = detail.activePanel === 'request' ? REQUEST_TABS : RESPONSE_TABS;
155
+ const currentTab = detail.activePanel === 'request'
156
+ ? detail.activeRequestTab
157
+ : detail.activeResponseTab;
158
+ const index = tabs.indexOf(currentTab);
159
+ if (index === 0) {
160
+ dispatch(detailSetSubFocus('TITLE'));
161
+ }
162
+ else {
163
+ const prevTab = tabs[index - 1];
164
+ if (detail.activePanel === 'request') {
165
+ dispatch(detailSetRequestTab(prevTab));
166
+ }
167
+ else {
168
+ dispatch(detailSetResponseTab(prevTab));
169
+ }
170
+ }
171
+ }
172
+ }
100
173
  }
101
174
  };
102
175
  // l Key (Right)
@@ -109,6 +182,9 @@ export const navigateRight = () => (dispatch, getState) => {
109
182
  dispatch(listSelectTag(list.selectedTagIndex)); // 현재 태그 선택 유지하며 포커스만 이동
110
183
  dispatch(listSetFocus('LIST'));
111
184
  }
185
+ else if (list.focus === 'TITLE') {
186
+ dispatch(listSetFocus('METHODS'));
187
+ }
112
188
  else if (list.focus === 'LIST') {
113
189
  const nextIndex = (list.focusedMethodIndex + 1) % METHODS.length;
114
190
  dispatch(listSetFocus('METHODS'));
@@ -124,6 +200,28 @@ export const navigateRight = () => (dispatch, getState) => {
124
200
  else if (screen === 'DETAIL' && detail) {
125
201
  if (detail.activePanel === 'left') {
126
202
  dispatch(detailSetPanel('request'));
203
+ dispatch(detailSetSubFocus('TITLE'));
204
+ }
205
+ else {
206
+ if (detail.subFocus === 'TITLE') {
207
+ dispatch(detailSetSubFocus('TABS'));
208
+ }
209
+ else if (detail.subFocus === 'TABS') {
210
+ const tabs = detail.activePanel === 'request' ? REQUEST_TABS : RESPONSE_TABS;
211
+ const currentTab = detail.activePanel === 'request'
212
+ ? detail.activeRequestTab
213
+ : detail.activeResponseTab;
214
+ const index = tabs.indexOf(currentTab);
215
+ const nextIndex = (index + 1) % tabs.length;
216
+ const nextTab = tabs[nextIndex];
217
+ if (detail.activePanel === 'request') {
218
+ dispatch(detailSetRequestTab(nextTab));
219
+ }
220
+ else {
221
+ dispatch(detailSetResponseTab(nextTab));
222
+ }
223
+ }
224
+ // CONTENT: Do nothing or handle specific logic
127
225
  }
128
226
  }
129
227
  };
@@ -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' | 'CONTENT';
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' | 'CONTENT';
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.4",
4
4
  "description": "A terminal-based Swagger/OpenAPI explorer with Vim-style keyboard navigation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",