@gomjellie/lazyapi 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +162 -0
- package/dist/App.d.ts +9 -0
- package/dist/App.js +65 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +39 -0
- package/dist/components/common/ErrorMessage.d.ts +10 -0
- package/dist/components/common/ErrorMessage.js +17 -0
- package/dist/components/common/KeybindingFooter.d.ts +18 -0
- package/dist/components/common/KeybindingFooter.js +20 -0
- package/dist/components/common/LoadingSpinner.d.ts +9 -0
- package/dist/components/common/LoadingSpinner.js +15 -0
- package/dist/components/common/SearchInput.d.ts +14 -0
- package/dist/components/common/SearchInput.js +72 -0
- package/dist/components/common/StatusBar.d.ts +14 -0
- package/dist/components/common/StatusBar.js +36 -0
- package/dist/components/detail-view/DetailView.d.ts +5 -0
- package/dist/components/detail-view/DetailView.js +266 -0
- package/dist/components/detail-view/LeftPanel.d.ts +19 -0
- package/dist/components/detail-view/LeftPanel.js +363 -0
- package/dist/components/detail-view/ParameterForm.d.ts +17 -0
- package/dist/components/detail-view/ParameterForm.js +77 -0
- package/dist/components/detail-view/RightPanel.d.ts +22 -0
- package/dist/components/detail-view/RightPanel.js +251 -0
- package/dist/components/list-view/EndpointList.d.ts +12 -0
- package/dist/components/list-view/EndpointList.js +72 -0
- package/dist/components/list-view/Explorer.d.ts +13 -0
- package/dist/components/list-view/Explorer.js +83 -0
- package/dist/components/list-view/FilterBar.d.ts +12 -0
- package/dist/components/list-view/FilterBar.js +33 -0
- package/dist/components/list-view/ListView.d.ts +5 -0
- package/dist/components/list-view/ListView.js +304 -0
- package/dist/components/list-view/SearchBar.d.ts +11 -0
- package/dist/components/list-view/SearchBar.js +14 -0
- package/dist/hooks/useApiClient.d.ts +11 -0
- package/dist/hooks/useApiClient.js +34 -0
- package/dist/hooks/useKeyPress.d.ts +26 -0
- package/dist/hooks/useKeyPress.js +57 -0
- package/dist/hooks/useNavigation.d.ts +10 -0
- package/dist/hooks/useNavigation.js +33 -0
- package/dist/services/http-client.d.ts +21 -0
- package/dist/services/http-client.js +173 -0
- package/dist/services/openapi-parser.d.ts +47 -0
- package/dist/services/openapi-parser.js +318 -0
- package/dist/store/appSlice.d.ts +14 -0
- package/dist/store/appSlice.js +144 -0
- package/dist/store/hooks.d.ts +9 -0
- package/dist/store/hooks.js +7 -0
- package/dist/store/index.d.ts +12 -0
- package/dist/store/index.js +12 -0
- package/dist/types/app-state.d.ts +126 -0
- package/dist/types/app-state.js +4 -0
- package/dist/types/openapi.d.ts +86 -0
- package/dist/types/openapi.js +115 -0
- package/dist/utils/clipboard.d.ts +11 -0
- package/dist/utils/clipboard.js +26 -0
- package/dist/utils/formatters.d.ts +35 -0
- package/dist/utils/formatters.js +93 -0
- package/dist/utils/schema-formatter.d.ts +21 -0
- package/dist/utils/schema-formatter.js +301 -0
- package/dist/utils/validators.d.ts +16 -0
- package/dist/utils/validators.js +158 -0
- package/package.json +88 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 gomjellie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# lazyapi
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
<div align="center">
|
|
6
|
+
|
|
7
|
+
A **Vim-style TUI (Terminal User Interface)** tool for exploring Swagger/OpenAPI documents and testing APIs in the terminal
|
|
8
|
+
|
|
9
|
+
[](https://opensource.org/licenses/MIT)
|
|
10
|
+
[](https://nodejs.org)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## ✨ Features
|
|
17
|
+
|
|
18
|
+
- 🎹 **Vim-style keyboard navigation** - Familiar keybindings like h, j, k, l
|
|
19
|
+
- 📄 **Swagger 2.0 & OpenAPI 3.x support** - Supports both JSON/YAML formats
|
|
20
|
+
- 🔌 **tRPC support** - Coming soon
|
|
21
|
+
- 🎨 **Terminal-friendly UI** - Beautiful TUI with neon green theme
|
|
22
|
+
- ⚡ **Fast API testing** - Execute APIs directly from the terminal
|
|
23
|
+
- 🔍 **Search & filtering** - Quickly find endpoints
|
|
24
|
+
- 📋 **Clipboard support** - Copy response results to clipboard
|
|
25
|
+
|
|
26
|
+
## 📦 Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g @gomjellie/lazyapi
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or run the development version locally:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/gomjellie/lazyapi.git
|
|
36
|
+
cd lazyapi
|
|
37
|
+
pnpm install
|
|
38
|
+
pnpm run dev examples/sample-api.json
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 🚀 Usage
|
|
42
|
+
|
|
43
|
+
### Basic Usage
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
lazyapi <openapi-file>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Examples
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# JSON file
|
|
53
|
+
lazyapi openapi.json
|
|
54
|
+
|
|
55
|
+
# YAML file
|
|
56
|
+
lazyapi swagger.yaml
|
|
57
|
+
|
|
58
|
+
# URL (coming soon)
|
|
59
|
+
lazyapi https://api.example.com/openapi.json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## ⌨️ Keyboard Shortcuts
|
|
63
|
+
|
|
64
|
+
### List View
|
|
65
|
+
|
|
66
|
+
<img width="819" height="351" alt="image" src="https://github.com/user-attachments/assets/03743827-7467-4336-b14f-436fdd56ac78" />
|
|
67
|
+
|
|
68
|
+
| Key | Description |
|
|
69
|
+
| ------------- | -------------------------------------------------------------- |
|
|
70
|
+
| `j` | Move to next item |
|
|
71
|
+
| `k` | Move to previous item |
|
|
72
|
+
| `g` | Move to top of list |
|
|
73
|
+
| `G` | Move to bottom of list |
|
|
74
|
+
| `:m<1-6>` | Select Method (1:ALL, 2:GET, 3:POST, 4:PUT, 5:DELETE, 6:PATCH) |
|
|
75
|
+
| `l` / `Enter` | View details of selected endpoint |
|
|
76
|
+
| `/` | Enter search mode |
|
|
77
|
+
| `:q` | Quit |
|
|
78
|
+
|
|
79
|
+
#### Search Mode
|
|
80
|
+
|
|
81
|
+
| Key | Description |
|
|
82
|
+
| ---------- | ----------------------------------------------------- |
|
|
83
|
+
| Text input | Enter search query (searches path, description, tags) |
|
|
84
|
+
| `Esc` | Cancel search and return to NORMAL mode |
|
|
85
|
+
|
|
86
|
+
### Detail View
|
|
87
|
+
|
|
88
|
+
<img width="818" height="351" alt="image" src="https://github.com/user-attachments/assets/1bdaff1b-7839-4128-a370-a906fb17692a" />
|
|
89
|
+
|
|
90
|
+
#### NORMAL Mode
|
|
91
|
+
|
|
92
|
+
| Key | Description |
|
|
93
|
+
| ------- | ------------------------------ |
|
|
94
|
+
| `j` | Move to next field |
|
|
95
|
+
| `k` | Move to previous field |
|
|
96
|
+
| `i` | Enter INSERT mode (edit field) |
|
|
97
|
+
| `Enter` | Execute API request |
|
|
98
|
+
| `u` | Return to list view |
|
|
99
|
+
|
|
100
|
+
#### INSERT Mode
|
|
101
|
+
|
|
102
|
+
| Key | Description |
|
|
103
|
+
| ---------- | --------------------- |
|
|
104
|
+
| `Esc` | Switch to NORMAL mode |
|
|
105
|
+
| Text input | Edit field value |
|
|
106
|
+
|
|
107
|
+
### Result View
|
|
108
|
+
|
|
109
|
+
| Key | Description |
|
|
110
|
+
| --------- | ----------------------------------------- |
|
|
111
|
+
| `j` / `k` | Scroll response content |
|
|
112
|
+
| `Tab` | Switch to next tab (Body/Headers/Cookies) |
|
|
113
|
+
| `y` | Copy Body content to clipboard |
|
|
114
|
+
| `r` | Re-run request |
|
|
115
|
+
| `q` / `u` | Return to detail view |
|
|
116
|
+
|
|
117
|
+
## 🛠️ Development
|
|
118
|
+
|
|
119
|
+
### Requirements
|
|
120
|
+
|
|
121
|
+
- Node.js >= 20.0.0
|
|
122
|
+
- pnpm >= 8.0.0
|
|
123
|
+
|
|
124
|
+
## 📝 Future Plans
|
|
125
|
+
|
|
126
|
+
- [ ] Load OpenAPI documents from URL
|
|
127
|
+
- [ ] Multiple environment support (dev, staging, prod)
|
|
128
|
+
- [ ] Request history storage
|
|
129
|
+
- [ ] Favorites feature
|
|
130
|
+
- [ ] Custom theme support
|
|
131
|
+
- [ ] Plugin system
|
|
132
|
+
|
|
133
|
+
## 🤝 Contributing
|
|
134
|
+
|
|
135
|
+
Contributions are always welcome! Please open an issue or submit a PR.
|
|
136
|
+
|
|
137
|
+
1. Fork the Project
|
|
138
|
+
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
|
139
|
+
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
|
140
|
+
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
|
141
|
+
5. Open a Pull Request
|
|
142
|
+
|
|
143
|
+
## 📄 License
|
|
144
|
+
|
|
145
|
+
MIT License - see the LICENSE file for details.
|
|
146
|
+
|
|
147
|
+
## 👤 Author
|
|
148
|
+
|
|
149
|
+
**gomjellie**
|
|
150
|
+
|
|
151
|
+
- GitHub: [@gomjellie](https://github.com/gomjellie)
|
|
152
|
+
|
|
153
|
+
## 🙏 Acknowledgments
|
|
154
|
+
|
|
155
|
+
- [Ink](https://github.com/vadimdemedes/ink) - React-based terminal UI library
|
|
156
|
+
- [swagger-parser](https://github.com/APIDevTools/swagger-parser) - OpenAPI parser
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
<div align="center">
|
|
161
|
+
Made with ❤️ by gomjellie
|
|
162
|
+
</div>
|
package/dist/App.d.ts
ADDED
package/dist/App.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 메인 애플리케이션 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React, { useEffect, useState } from 'react';
|
|
5
|
+
import { Box, Text, useStdout } from 'ink';
|
|
6
|
+
import { Provider } from 'react-redux';
|
|
7
|
+
import { store } from './store/index.js';
|
|
8
|
+
import { useAppSelector, useAppDispatch } from './store/hooks.js';
|
|
9
|
+
import { setDocument, setEndpoints, setLoading, setError } from './store/appSlice.js';
|
|
10
|
+
import { OpenAPIParserService } from './services/openapi-parser.js';
|
|
11
|
+
import ListView from './components/list-view/ListView.js';
|
|
12
|
+
import DetailView from './components/detail-view/DetailView.js';
|
|
13
|
+
import LoadingSpinner from './components/common/LoadingSpinner.js';
|
|
14
|
+
import ErrorMessage from './components/common/ErrorMessage.js';
|
|
15
|
+
function AppContent({ filePath }) {
|
|
16
|
+
const screen = useAppSelector((state) => state.app.screen);
|
|
17
|
+
const loading = useAppSelector((state) => state.app.loading);
|
|
18
|
+
const error = useAppSelector((state) => state.app.error);
|
|
19
|
+
const dispatch = useAppDispatch();
|
|
20
|
+
const [parser] = useState(() => new OpenAPIParserService());
|
|
21
|
+
const { stdout } = useStdout();
|
|
22
|
+
const terminalHeight = stdout?.rows || 24;
|
|
23
|
+
// OpenAPI 문서 로드
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const loadDocument = async () => {
|
|
26
|
+
dispatch(setLoading(true));
|
|
27
|
+
try {
|
|
28
|
+
const document = await parser.parseFromFile(filePath);
|
|
29
|
+
const endpoints = parser.extractEndpoints(document);
|
|
30
|
+
dispatch(setDocument(document));
|
|
31
|
+
dispatch(setEndpoints(endpoints));
|
|
32
|
+
dispatch(setLoading(false));
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
const errorMessage = err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다';
|
|
36
|
+
dispatch(setError(errorMessage));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
loadDocument();
|
|
40
|
+
}, [filePath, parser, dispatch]);
|
|
41
|
+
// 로딩 상태
|
|
42
|
+
if (loading) {
|
|
43
|
+
return (React.createElement(Box, { flexDirection: "column", padding: 1, height: terminalHeight },
|
|
44
|
+
React.createElement(LoadingSpinner, { message: "OpenAPI \uBB38\uC11C\uB97C \uB85C\uB529\uD558\uB294 \uC911..." })));
|
|
45
|
+
}
|
|
46
|
+
// 에러 상태
|
|
47
|
+
if (error) {
|
|
48
|
+
return (React.createElement(Box, { flexDirection: "column", padding: 1, height: terminalHeight },
|
|
49
|
+
React.createElement(ErrorMessage, { message: error })));
|
|
50
|
+
}
|
|
51
|
+
// 화면 렌더링
|
|
52
|
+
switch (screen) {
|
|
53
|
+
case 'LIST':
|
|
54
|
+
return React.createElement(ListView, null);
|
|
55
|
+
case 'DETAIL':
|
|
56
|
+
return React.createElement(DetailView, null);
|
|
57
|
+
default:
|
|
58
|
+
return (React.createElement(Box, null,
|
|
59
|
+
React.createElement(Text, null, "\uC54C \uC218 \uC5C6\uB294 \uD654\uBA74 \uC0C1\uD0DC\uC785\uB2C8\uB2E4.")));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export default function App({ filePath }) {
|
|
63
|
+
return (React.createElement(Provider, { store: store },
|
|
64
|
+
React.createElement(AppContent, { filePath: filePath })));
|
|
65
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI 엔트리 포인트
|
|
4
|
+
*/
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { render } from 'ink';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import App from './App.js';
|
|
9
|
+
// 명령줄 인자 파싱
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
if (args.length === 0) {
|
|
12
|
+
console.error('사용법: swagger-cli <file>');
|
|
13
|
+
console.error('');
|
|
14
|
+
console.error('예시:');
|
|
15
|
+
console.error(' swagger-cli openapi.json');
|
|
16
|
+
console.error(' swagger-cli swagger.yaml');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const filePath = path.resolve(process.cwd(), args[0]);
|
|
20
|
+
// 터미널 클리어하여 깨끗한 화면에서 시작
|
|
21
|
+
console.clear();
|
|
22
|
+
// 앱 렌더링
|
|
23
|
+
const { waitUntilExit } = render(React.createElement(App, { filePath: filePath }), {
|
|
24
|
+
// stdout을 명시적으로 지정하여 출력 위치 고정
|
|
25
|
+
stdout: process.stdout,
|
|
26
|
+
stdin: process.stdin,
|
|
27
|
+
// exitOnCtrlC를 true로 설정하여 Ctrl+C로 종료 가능
|
|
28
|
+
exitOnCtrlC: true,
|
|
29
|
+
// 콘솔 패치를 활성화하여 console.log 등이 화면을 방해하지 않도록
|
|
30
|
+
patchConsole: true,
|
|
31
|
+
});
|
|
32
|
+
waitUntilExit()
|
|
33
|
+
.then(() => {
|
|
34
|
+
process.exit(0);
|
|
35
|
+
})
|
|
36
|
+
.catch((error) => {
|
|
37
|
+
console.error('애플리케이션 오류:', error.message);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 에러 메시지 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
export default function ErrorMessage({ message, details }) {
|
|
7
|
+
return (React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", padding: 1, marginY: 1 },
|
|
8
|
+
React.createElement(Box, { marginBottom: details ? 1 : 0 },
|
|
9
|
+
React.createElement(Text, { bold: true, color: "red" },
|
|
10
|
+
"\u2716 \uC624\uB958:",
|
|
11
|
+
' '),
|
|
12
|
+
React.createElement(Text, { color: "red" }, message)),
|
|
13
|
+
details && (React.createElement(Box, { marginLeft: 2 },
|
|
14
|
+
React.createElement(Text, { dimColor: true }, details))),
|
|
15
|
+
React.createElement(Box, { marginTop: 1 },
|
|
16
|
+
React.createElement(Text, { dimColor: true }, "q \uD0A4\uB97C \uB20C\uB7EC \uC885\uB8CC\uD558\uAC70\uB098 Ctrl+C\uB97C \uB20C\uB7EC\uC8FC\uC138\uC694."))));
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 하단 키바인딩 가이드 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
export interface KeyBinding {
|
|
6
|
+
key: string;
|
|
7
|
+
description: string;
|
|
8
|
+
}
|
|
9
|
+
interface KeybindingFooterProps {
|
|
10
|
+
bindings: KeyBinding[];
|
|
11
|
+
mode?: string;
|
|
12
|
+
additionalInfo?: string;
|
|
13
|
+
commandInput?: string;
|
|
14
|
+
onCommandChange?: (value: string) => void;
|
|
15
|
+
onCommandSubmit?: (value: string) => void;
|
|
16
|
+
}
|
|
17
|
+
export default function KeybindingFooter({ bindings, mode, additionalInfo, commandInput, onCommandChange, onCommandSubmit, }: KeybindingFooterProps): React.JSX.Element;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 하단 키바인딩 가이드 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
import TextInput from 'ink-text-input';
|
|
7
|
+
export default function KeybindingFooter({ bindings, mode, additionalInfo, commandInput = '', onCommandChange, onCommandSubmit, }) {
|
|
8
|
+
return (React.createElement(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "green", paddingX: 1, paddingTop: 0 },
|
|
9
|
+
mode && (React.createElement(Box, { marginRight: 2 },
|
|
10
|
+
React.createElement(Text, { bold: true, color: "black", backgroundColor: "green" }, ' ' + mode + ' '))),
|
|
11
|
+
mode === 'COMMAND' && onCommandChange && onCommandSubmit ? (React.createElement(Box, { flexGrow: 1 },
|
|
12
|
+
React.createElement(Text, { color: "yellow" }, ":"),
|
|
13
|
+
React.createElement(TextInput, { value: commandInput, onChange: onCommandChange, onSubmit: onCommandSubmit }))) : (React.createElement(Box, { flexGrow: 1, gap: 3, flexWrap: "wrap" }, bindings.map((binding, index) => (React.createElement(Box, { key: `${binding.key}-${binding.description}-${index}` },
|
|
14
|
+
React.createElement(Text, { bold: true, color: "green" }, binding.key),
|
|
15
|
+
React.createElement(Text, null,
|
|
16
|
+
" ",
|
|
17
|
+
binding.description)))))),
|
|
18
|
+
additionalInfo && (React.createElement(Box, null,
|
|
19
|
+
React.createElement(Text, { dimColor: true }, additionalInfo)))));
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 로딩 스피너 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
import Spinner from 'ink-spinner';
|
|
7
|
+
export default function LoadingSpinner({ message = '로딩 중...' }) {
|
|
8
|
+
return (React.createElement(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", minHeight: 10 },
|
|
9
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
10
|
+
React.createElement(Text, { color: "green" },
|
|
11
|
+
React.createElement(Spinner, { type: "dots" })),
|
|
12
|
+
React.createElement(Text, { color: "green", bold: true },
|
|
13
|
+
' ',
|
|
14
|
+
message))));
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 자동완성 기능이 있는 검색 입력 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
interface SearchInputProps {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
suggestions?: string[];
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
focus?: boolean;
|
|
11
|
+
onSubmit?: () => void;
|
|
12
|
+
}
|
|
13
|
+
export default function SearchInput({ value, onChange, suggestions, placeholder, focus, onSubmit, }: SearchInputProps): React.JSX.Element;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 자동완성 기능이 있는 검색 입력 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React, { useState, useMemo } from 'react';
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
import { useInput } from 'ink';
|
|
7
|
+
export default function SearchInput({ value, onChange, suggestions = [], placeholder = '', focus = true, onSubmit, }) {
|
|
8
|
+
const [suggestionIndex, setSuggestionIndex] = useState(0);
|
|
9
|
+
const [lastTypedValue, setLastTypedValue] = useState(value);
|
|
10
|
+
// 현재 입력값과 매칭되는 모든 suggestions 찾기
|
|
11
|
+
const matchingSuggestions = useMemo(() => {
|
|
12
|
+
if (!lastTypedValue || suggestions.length === 0) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return suggestions.filter((suggestion) => suggestion.toLowerCase().startsWith(lastTypedValue.toLowerCase()) &&
|
|
16
|
+
suggestion.toLowerCase() !== lastTypedValue.toLowerCase());
|
|
17
|
+
}, [lastTypedValue, suggestions]);
|
|
18
|
+
// 현재 선택된 suggestion
|
|
19
|
+
const currentSuggestion = useMemo(() => {
|
|
20
|
+
if (matchingSuggestions.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
return matchingSuggestions[suggestionIndex % matchingSuggestions.length];
|
|
23
|
+
}, [matchingSuggestions, suggestionIndex]);
|
|
24
|
+
// Ghost text 계산
|
|
25
|
+
const ghostText = useMemo(() => {
|
|
26
|
+
if (!currentSuggestion || !lastTypedValue)
|
|
27
|
+
return '';
|
|
28
|
+
return currentSuggestion.slice(lastTypedValue.length);
|
|
29
|
+
}, [currentSuggestion, lastTypedValue]);
|
|
30
|
+
// 키 입력 처리
|
|
31
|
+
useInput((input, key) => {
|
|
32
|
+
if (!focus)
|
|
33
|
+
return;
|
|
34
|
+
if (key.tab) {
|
|
35
|
+
// Tab: ghost text를 자동완성하고 다음 suggestion으로 이동
|
|
36
|
+
if (currentSuggestion && ghostText) {
|
|
37
|
+
onChange(currentSuggestion);
|
|
38
|
+
setLastTypedValue(lastTypedValue); // 타이핑한 부분은 유지
|
|
39
|
+
setSuggestionIndex((prev) => prev + 1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if (key.return) {
|
|
43
|
+
// Enter: 부모에게 알림 (INSERT 모드 종료)
|
|
44
|
+
if (onSubmit) {
|
|
45
|
+
onSubmit();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (key.backspace || key.delete) {
|
|
49
|
+
// Backspace/Delete
|
|
50
|
+
if (value.length > 0) {
|
|
51
|
+
const newValue = value.slice(0, -1);
|
|
52
|
+
onChange(newValue);
|
|
53
|
+
setLastTypedValue(newValue);
|
|
54
|
+
setSuggestionIndex(0);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (key.escape) {
|
|
58
|
+
// Escape: 입력 취소 (부모가 처리)
|
|
59
|
+
}
|
|
60
|
+
else if (!key.ctrl && !key.meta && input) {
|
|
61
|
+
// 일반 문자 입력
|
|
62
|
+
const newValue = value + input;
|
|
63
|
+
onChange(newValue);
|
|
64
|
+
setLastTypedValue(newValue);
|
|
65
|
+
setSuggestionIndex(0);
|
|
66
|
+
}
|
|
67
|
+
}, { isActive: focus });
|
|
68
|
+
return (React.createElement(Box, null,
|
|
69
|
+
React.createElement(Text, { backgroundColor: focus ? 'green' : undefined }, value),
|
|
70
|
+
ghostText && (React.createElement(Text, { dimColor: true, color: "gray" }, ghostText)),
|
|
71
|
+
!value && !ghostText && placeholder && (React.createElement(Text, { dimColor: true, color: "gray" }, placeholder))));
|
|
72
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 상단 상태 바 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { AppMode } from '../../types/app-state.js';
|
|
6
|
+
interface StatusBarProps {
|
|
7
|
+
title: string;
|
|
8
|
+
version?: string;
|
|
9
|
+
currentPath?: string;
|
|
10
|
+
mode: AppMode;
|
|
11
|
+
status?: string;
|
|
12
|
+
}
|
|
13
|
+
export default function StatusBar({ title, version, currentPath, mode, status }: StatusBarProps): React.JSX.Element;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 상단 상태 바 컴포넌트
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
export default function StatusBar({ title, version, currentPath, mode, status }) {
|
|
7
|
+
const getModeColor = () => {
|
|
8
|
+
switch (mode) {
|
|
9
|
+
case 'INSERT':
|
|
10
|
+
return 'yellow';
|
|
11
|
+
case 'SEARCH':
|
|
12
|
+
return 'cyan';
|
|
13
|
+
default:
|
|
14
|
+
return 'green';
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
return (React.createElement(Box, { borderStyle: "single", borderTop: false, borderLeft: false, borderRight: false, borderColor: "green", paddingX: 1, paddingBottom: 0 },
|
|
18
|
+
React.createElement(Box, { flexGrow: 1 },
|
|
19
|
+
React.createElement(Text, { bold: true, color: "green" },
|
|
20
|
+
"\u25A0 ",
|
|
21
|
+
title),
|
|
22
|
+
version && React.createElement(Text, { dimColor: true },
|
|
23
|
+
" v",
|
|
24
|
+
version),
|
|
25
|
+
currentPath && React.createElement(Text, { dimColor: true },
|
|
26
|
+
" | ",
|
|
27
|
+
currentPath)),
|
|
28
|
+
React.createElement(Box, { gap: 2 },
|
|
29
|
+
status && React.createElement(Text, { color: "green" },
|
|
30
|
+
"\u25CF ",
|
|
31
|
+
status),
|
|
32
|
+
React.createElement(Text, { bold: true, color: getModeColor() },
|
|
33
|
+
"[",
|
|
34
|
+
mode,
|
|
35
|
+
"]"))));
|
|
36
|
+
}
|