lazyreview 0.1.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/.github/workflows/publish-npm.yml +51 -0
- package/.prettierrc +7 -0
- package/CLAUDE.md +50 -0
- package/README.md +119 -0
- package/dist/cli.js +3830 -0
- package/dist/cli.js.map +1 -0
- package/package.json +64 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/app.tsx +235 -0
- package/src/cli.tsx +78 -0
- package/src/components/common/BorderedBox.tsx +41 -0
- package/src/components/common/Divider.tsx +31 -0
- package/src/components/common/EmptyState.tsx +35 -0
- package/src/components/common/FilterModal.tsx +117 -0
- package/src/components/common/LoadingIndicator.tsx +31 -0
- package/src/components/common/Modal.tsx +24 -0
- package/src/components/common/PaginationBar.tsx +56 -0
- package/src/components/common/SortModal.tsx +91 -0
- package/src/components/common/Spinner.tsx +28 -0
- package/src/components/common/index.ts +9 -0
- package/src/components/layout/HelpModal.tsx +61 -0
- package/src/components/layout/MainPanel.tsx +26 -0
- package/src/components/layout/Sidebar.tsx +71 -0
- package/src/components/layout/StatusBar.tsx +42 -0
- package/src/components/layout/TokenInputModal.tsx +92 -0
- package/src/components/layout/TopBar.tsx +44 -0
- package/src/components/layout/index.ts +6 -0
- package/src/components/pr/CommentsTab.tsx +92 -0
- package/src/components/pr/CommitsTab.tsx +142 -0
- package/src/components/pr/ConversationsTab.tsx +273 -0
- package/src/components/pr/FilesTab.tsx +532 -0
- package/src/components/pr/PRHeader.tsx +69 -0
- package/src/components/pr/PRListItem.tsx +92 -0
- package/src/components/pr/PRTabs.tsx +50 -0
- package/src/components/pr/index.ts +8 -0
- package/src/hooks/index.ts +12 -0
- package/src/hooks/useActivePanel.ts +54 -0
- package/src/hooks/useAppKeymap.ts +22 -0
- package/src/hooks/useAuth.ts +131 -0
- package/src/hooks/useConfig.ts +52 -0
- package/src/hooks/useFilter.ts +192 -0
- package/src/hooks/useGitHub.ts +260 -0
- package/src/hooks/useInputFocus.tsx +35 -0
- package/src/hooks/useListNavigation.ts +121 -0
- package/src/hooks/useLoading.ts +25 -0
- package/src/hooks/usePagination.ts +87 -0
- package/src/models/comment.ts +15 -0
- package/src/models/commit.ts +20 -0
- package/src/models/diff.ts +93 -0
- package/src/models/errors.ts +24 -0
- package/src/models/file-change.ts +22 -0
- package/src/models/index.ts +12 -0
- package/src/models/pull-request.ts +40 -0
- package/src/models/review.ts +17 -0
- package/src/models/user.ts +9 -0
- package/src/screens/InvolvedScreen.tsx +161 -0
- package/src/screens/MyPRsScreen.tsx +161 -0
- package/src/screens/PRDetailScreen.tsx +88 -0
- package/src/screens/PRListScreen.tsx +96 -0
- package/src/screens/ReviewRequestsScreen.tsx +161 -0
- package/src/screens/SettingsScreen.tsx +284 -0
- package/src/screens/ThisRepoScreen.tsx +175 -0
- package/src/screens/index.ts +7 -0
- package/src/services/Auth.ts +314 -0
- package/src/services/Config.ts +81 -0
- package/src/services/GitHubApi.ts +262 -0
- package/src/services/Loading.ts +54 -0
- package/src/services/index.ts +27 -0
- package/src/theme/index.ts +28 -0
- package/src/theme/themes.ts +84 -0
- package/src/theme/types.ts +28 -0
- package/src/utils/date.ts +28 -0
- package/src/utils/git.ts +67 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/terminal.ts +25 -0
- package/tsconfig.json +24 -0
- package/tsup.config.ts +13 -0
- package/vitest.config.ts +26 -0
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lazyreview",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "A TUI code review tool for GitHub PRs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Tauan Camargo",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/tauantcamargo/lazyreview.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"tui",
|
|
14
|
+
"github",
|
|
15
|
+
"pr",
|
|
16
|
+
"code review",
|
|
17
|
+
"lazyreview",
|
|
18
|
+
"terminal"
|
|
19
|
+
],
|
|
20
|
+
"bin": {
|
|
21
|
+
"lazyreview": "dist/cli.js"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsup",
|
|
25
|
+
"start": "node dist/cli.js",
|
|
26
|
+
"dev": "tsup --watch",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"test:coverage": "vitest run --coverage",
|
|
31
|
+
"lint": "prettier --check 'src/**/*.{ts,tsx}'",
|
|
32
|
+
"format": "prettier --write 'src/**/*.{ts,tsx}'"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@effect/platform": "^0.77.0",
|
|
36
|
+
"@inkjs/ui": "^2.0.0",
|
|
37
|
+
"@tanstack/react-query": "^5.66.0",
|
|
38
|
+
"date-fns": "^4.1.0",
|
|
39
|
+
"effect": "^3.14.0",
|
|
40
|
+
"ink": "^6.7.0",
|
|
41
|
+
"ink-confirm-input": "^2.0.0",
|
|
42
|
+
"ink-divider": "^4.1.1",
|
|
43
|
+
"ink-select-input": "^6.2.0",
|
|
44
|
+
"ink-scroll-list": "^0.4.1",
|
|
45
|
+
"ink-scroll-view": "^0.3.5",
|
|
46
|
+
"ink-syntax-highlight": "^2.0.2",
|
|
47
|
+
"ink-tab": "^5.2.0",
|
|
48
|
+
"react": "^19.0.0",
|
|
49
|
+
"yaml": "^2.7.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^22.13.0",
|
|
53
|
+
"@types/react": "^19.0.0",
|
|
54
|
+
"@vitest/coverage-v8": "^3.0.0",
|
|
55
|
+
"ink-testing-library": "^4.0.0",
|
|
56
|
+
"prettier": "^3.5.0",
|
|
57
|
+
"tsup": "^8.4.0",
|
|
58
|
+
"typescript": "^5.7.0",
|
|
59
|
+
"vitest": "^3.0.0"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=20"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/app.tsx
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react'
|
|
2
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
3
|
+
import { Box, useApp, useInput, useStdout } from 'ink'
|
|
4
|
+
import { ThemeProvider, getThemeByName } from './theme/index'
|
|
5
|
+
import type { ThemeName } from './theme/index'
|
|
6
|
+
import { TopBar } from './components/layout/TopBar'
|
|
7
|
+
import { Sidebar, SIDEBAR_ITEMS } from './components/layout/Sidebar'
|
|
8
|
+
import { MainPanel } from './components/layout/MainPanel'
|
|
9
|
+
import { StatusBar } from './components/layout/StatusBar'
|
|
10
|
+
import { HelpModal } from './components/layout/HelpModal'
|
|
11
|
+
import { TokenInputModal } from './components/layout/TokenInputModal'
|
|
12
|
+
import { PRDetailScreen } from './screens/PRDetailScreen'
|
|
13
|
+
import { MyPRsScreen } from './screens/MyPRsScreen'
|
|
14
|
+
import { ReviewRequestsScreen } from './screens/ReviewRequestsScreen'
|
|
15
|
+
import { SettingsScreen } from './screens/SettingsScreen'
|
|
16
|
+
import { InvolvedScreen } from './screens/InvolvedScreen'
|
|
17
|
+
import { ThisRepoScreen } from './screens/ThisRepoScreen'
|
|
18
|
+
import { Match } from 'effect'
|
|
19
|
+
import { useAuth } from './hooks/useAuth'
|
|
20
|
+
import { useConfig } from './hooks/useConfig'
|
|
21
|
+
import { useListNavigation } from './hooks/useListNavigation'
|
|
22
|
+
import { useActivePanel } from './hooks/useActivePanel'
|
|
23
|
+
import { InputFocusProvider, useInputFocus } from './hooks/useInputFocus'
|
|
24
|
+
import type { PullRequest } from './models/pull-request'
|
|
25
|
+
|
|
26
|
+
type AppScreen =
|
|
27
|
+
| { readonly type: 'list' }
|
|
28
|
+
| { readonly type: 'detail'; readonly pr: PullRequest }
|
|
29
|
+
|
|
30
|
+
interface AppContentProps {
|
|
31
|
+
readonly repoOwner: string | null
|
|
32
|
+
readonly repoName: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function AppContent({
|
|
36
|
+
repoOwner,
|
|
37
|
+
repoName,
|
|
38
|
+
}: AppContentProps): React.ReactElement {
|
|
39
|
+
const { exit } = useApp()
|
|
40
|
+
const { stdout } = useStdout()
|
|
41
|
+
const { user, isAuthenticated, loading, saveToken, error } = useAuth()
|
|
42
|
+
const [sidebarVisible, setSidebarVisible] = useState(true)
|
|
43
|
+
const [currentScreen, setCurrentScreen] = useState<AppScreen>({
|
|
44
|
+
type: 'list',
|
|
45
|
+
})
|
|
46
|
+
const [tokenError, setTokenError] = useState<string | null>(null)
|
|
47
|
+
const [showHelp, setShowHelp] = useState(false)
|
|
48
|
+
// Start with modal hidden, show only after auth check fails
|
|
49
|
+
const [showTokenInput, setShowTokenInput] = useState(false)
|
|
50
|
+
|
|
51
|
+
// Panel focus management
|
|
52
|
+
const { activePanel, setActivePanel } = useActivePanel({
|
|
53
|
+
hasSelection: currentScreen.type === 'detail',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Input focus tracking (for disabling shortcuts when typing)
|
|
57
|
+
const { isInputActive } = useInputFocus()
|
|
58
|
+
|
|
59
|
+
// Sidebar navigation
|
|
60
|
+
const { selectedIndex: sidebarIndex } = useListNavigation({
|
|
61
|
+
itemCount: SIDEBAR_ITEMS.length,
|
|
62
|
+
viewportHeight: SIDEBAR_ITEMS.length,
|
|
63
|
+
isActive: activePanel === 'sidebar' && !showHelp && !showTokenInput,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Show token modal when not authenticated (covers invalid token case)
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
if (!loading && !isAuthenticated && !showTokenInput) {
|
|
69
|
+
setShowTokenInput(true)
|
|
70
|
+
} else if (isAuthenticated && showTokenInput) {
|
|
71
|
+
setShowTokenInput(false)
|
|
72
|
+
}
|
|
73
|
+
}, [loading, isAuthenticated, showTokenInput])
|
|
74
|
+
|
|
75
|
+
const handleTokenSubmit = useCallback(
|
|
76
|
+
async (token: string) => {
|
|
77
|
+
try {
|
|
78
|
+
setTokenError(null)
|
|
79
|
+
await saveToken(token)
|
|
80
|
+
} catch (err) {
|
|
81
|
+
setTokenError(String(err))
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
[saveToken],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
// Global keyboard shortcuts
|
|
88
|
+
useInput(
|
|
89
|
+
(input, key) => {
|
|
90
|
+
// Handle modals first
|
|
91
|
+
if (showHelp || showTokenInput) {
|
|
92
|
+
if (key.escape || (showHelp && input === '?')) {
|
|
93
|
+
setShowHelp(false)
|
|
94
|
+
}
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (input === 'b') {
|
|
99
|
+
setSidebarVisible((prev) => !prev)
|
|
100
|
+
} else if (input === '?') {
|
|
101
|
+
setShowHelp(true)
|
|
102
|
+
} else if (input === 'q') {
|
|
103
|
+
if (currentScreen.type === 'detail') {
|
|
104
|
+
setCurrentScreen({ type: 'list' })
|
|
105
|
+
} else {
|
|
106
|
+
exit()
|
|
107
|
+
}
|
|
108
|
+
} else if (key.return && activePanel === 'sidebar') {
|
|
109
|
+
setActivePanel('list')
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
{ isActive: !showTokenInput && !isInputActive },
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
const handleSelectPR = useCallback((pr: PullRequest) => {
|
|
116
|
+
setCurrentScreen({ type: 'detail', pr })
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
const handleBackToList = useCallback(() => {
|
|
120
|
+
setCurrentScreen({ type: 'list' })
|
|
121
|
+
}, [])
|
|
122
|
+
|
|
123
|
+
function renderScreen(): React.ReactElement {
|
|
124
|
+
if (currentScreen.type === 'detail') {
|
|
125
|
+
// Extract owner/repo from PR URL for detail view
|
|
126
|
+
const prUrl = currentScreen.pr.html_url
|
|
127
|
+
const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull/)
|
|
128
|
+
const prOwner = match?.[1] ?? repoOwner ?? ''
|
|
129
|
+
const prRepo = match?.[2] ?? repoName ?? ''
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<PRDetailScreen
|
|
133
|
+
pr={currentScreen.pr}
|
|
134
|
+
owner={prOwner}
|
|
135
|
+
repo={prRepo}
|
|
136
|
+
onBack={handleBackToList}
|
|
137
|
+
/>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Navigation:
|
|
142
|
+
// 0 - Involved (all PRs user is involved in)
|
|
143
|
+
// 1 - My PRs (PRs user created)
|
|
144
|
+
// 2 - For Review (PRs requesting user's review)
|
|
145
|
+
// 3 - This Repo (PRs from current git directory)
|
|
146
|
+
// 4 - Settings
|
|
147
|
+
return Match.value(sidebarIndex).pipe(
|
|
148
|
+
Match.when(0, () => <InvolvedScreen onSelect={handleSelectPR} />),
|
|
149
|
+
Match.when(1, () => <MyPRsScreen onSelect={handleSelectPR} />),
|
|
150
|
+
Match.when(2, () => <ReviewRequestsScreen onSelect={handleSelectPR} />),
|
|
151
|
+
Match.when(3, () => (
|
|
152
|
+
<ThisRepoScreen
|
|
153
|
+
owner={repoOwner}
|
|
154
|
+
repo={repoName}
|
|
155
|
+
onSelect={handleSelectPR}
|
|
156
|
+
/>
|
|
157
|
+
)),
|
|
158
|
+
Match.when(4, () => <SettingsScreen />),
|
|
159
|
+
Match.orElse(() => <InvolvedScreen onSelect={handleSelectPR} />),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const terminalHeight = stdout?.rows ?? 24
|
|
164
|
+
|
|
165
|
+
const repoPath =
|
|
166
|
+
repoOwner && repoName ? `${repoOwner}/${repoName}` : undefined
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<Box flexDirection="column" height={terminalHeight}>
|
|
170
|
+
<TopBar
|
|
171
|
+
username={user?.login ?? 'anonymous'}
|
|
172
|
+
provider="github"
|
|
173
|
+
repoPath={repoPath}
|
|
174
|
+
/>
|
|
175
|
+
<Box flexDirection="row" flexGrow={1}>
|
|
176
|
+
<Sidebar
|
|
177
|
+
selectedIndex={sidebarIndex}
|
|
178
|
+
visible={sidebarVisible}
|
|
179
|
+
isActive={activePanel === 'sidebar'}
|
|
180
|
+
/>
|
|
181
|
+
<MainPanel isActive={activePanel === 'list'}>
|
|
182
|
+
{renderScreen()}
|
|
183
|
+
</MainPanel>
|
|
184
|
+
</Box>
|
|
185
|
+
<StatusBar activePanel={activePanel} />
|
|
186
|
+
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
|
|
187
|
+
{showTokenInput && (
|
|
188
|
+
<TokenInputModal
|
|
189
|
+
onSubmit={handleTokenSubmit}
|
|
190
|
+
onClose={() => setShowTokenInput(false)}
|
|
191
|
+
error={tokenError ?? error}
|
|
192
|
+
/>
|
|
193
|
+
)}
|
|
194
|
+
</Box>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const queryClient = new QueryClient({
|
|
199
|
+
defaultOptions: {
|
|
200
|
+
queries: {
|
|
201
|
+
staleTime: 1000 * 60, // 1 minute
|
|
202
|
+
retry: 1,
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
interface AppProps {
|
|
208
|
+
readonly repoOwner: string | null
|
|
209
|
+
readonly repoName: string | null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function AppWithTheme({
|
|
213
|
+
repoOwner,
|
|
214
|
+
repoName,
|
|
215
|
+
}: AppProps): React.ReactElement {
|
|
216
|
+
const { config } = useConfig()
|
|
217
|
+
const themeName = (config?.theme ?? 'tokyo-night') as ThemeName
|
|
218
|
+
const theme = getThemeByName(themeName)
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<ThemeProvider theme={theme}>
|
|
222
|
+
<AppContent repoOwner={repoOwner} repoName={repoName} />
|
|
223
|
+
</ThemeProvider>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function App({ repoOwner, repoName }: AppProps): React.ReactElement {
|
|
228
|
+
return (
|
|
229
|
+
<QueryClientProvider client={queryClient}>
|
|
230
|
+
<InputFocusProvider>
|
|
231
|
+
<AppWithTheme repoOwner={repoOwner} repoName={repoName} />
|
|
232
|
+
</InputFocusProvider>
|
|
233
|
+
</QueryClientProvider>
|
|
234
|
+
)
|
|
235
|
+
}
|
package/src/cli.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { render } from 'ink'
|
|
3
|
+
import { App } from './app'
|
|
4
|
+
import { detectGitRepo } from './utils/git'
|
|
5
|
+
|
|
6
|
+
// ANSI escape codes for alternate screen buffer
|
|
7
|
+
const ENTER_ALT_SCREEN = '\x1b[?1049h'
|
|
8
|
+
const EXIT_ALT_SCREEN = '\x1b[?1049l'
|
|
9
|
+
const CLEAR_SCREEN = '\x1b[2J'
|
|
10
|
+
const CURSOR_HOME = '\x1b[H'
|
|
11
|
+
const HIDE_CURSOR = '\x1b[?25l'
|
|
12
|
+
const SHOW_CURSOR = '\x1b[?25h'
|
|
13
|
+
|
|
14
|
+
interface RepoInfo {
|
|
15
|
+
readonly owner: string | null
|
|
16
|
+
readonly repo: string | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv: string[]): RepoInfo | null {
|
|
20
|
+
const repoArg = argv[2]
|
|
21
|
+
|
|
22
|
+
if (repoArg && repoArg.includes('/')) {
|
|
23
|
+
const [owner, repo] = repoArg.split('/')
|
|
24
|
+
if (owner && repo) {
|
|
25
|
+
return { owner, repo }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Cleanup on exit
|
|
33
|
+
function cleanup(): void {
|
|
34
|
+
process.stdout.write(SHOW_CURSOR + EXIT_ALT_SCREEN)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main(): Promise<void> {
|
|
38
|
+
// Enter alternate screen buffer
|
|
39
|
+
process.stdout.write(
|
|
40
|
+
ENTER_ALT_SCREEN + CLEAR_SCREEN + CURSOR_HOME + HIDE_CURSOR,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
process.on('exit', cleanup)
|
|
44
|
+
process.on('SIGINT', () => {
|
|
45
|
+
cleanup()
|
|
46
|
+
process.exit(0)
|
|
47
|
+
})
|
|
48
|
+
process.on('SIGTERM', () => {
|
|
49
|
+
cleanup()
|
|
50
|
+
process.exit(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Check for CLI args first
|
|
54
|
+
const argsRepo = parseArgs(process.argv)
|
|
55
|
+
|
|
56
|
+
// If no args, try to detect from current git repo
|
|
57
|
+
let repoOwner: string | null = null
|
|
58
|
+
let repoName: string | null = null
|
|
59
|
+
|
|
60
|
+
if (argsRepo) {
|
|
61
|
+
repoOwner = argsRepo.owner
|
|
62
|
+
repoName = argsRepo.repo
|
|
63
|
+
} else {
|
|
64
|
+
const gitInfo = await detectGitRepo()
|
|
65
|
+
if (gitInfo.isGitRepo && gitInfo.owner && gitInfo.repo) {
|
|
66
|
+
repoOwner = gitInfo.owner
|
|
67
|
+
repoName = gitInfo.repo
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
render(<App repoOwner={repoOwner} repoName={repoName} />)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
main().catch((error) => {
|
|
75
|
+
cleanup()
|
|
76
|
+
console.error('Failed to start:', error)
|
|
77
|
+
process.exit(1)
|
|
78
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { useTheme } from '../../theme/index'
|
|
4
|
+
|
|
5
|
+
interface BorderedBoxProps {
|
|
6
|
+
readonly title: string
|
|
7
|
+
readonly width?: number | string
|
|
8
|
+
readonly height?: number | string
|
|
9
|
+
readonly isActive?: boolean
|
|
10
|
+
readonly children: React.ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function BorderedBox({
|
|
14
|
+
title,
|
|
15
|
+
width,
|
|
16
|
+
height,
|
|
17
|
+
isActive = false,
|
|
18
|
+
children,
|
|
19
|
+
}: BorderedBoxProps): React.ReactElement {
|
|
20
|
+
const theme = useTheme()
|
|
21
|
+
const borderColor = isActive ? theme.colors.accent : theme.colors.border
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Box
|
|
25
|
+
flexDirection="column"
|
|
26
|
+
width={width}
|
|
27
|
+
height={height}
|
|
28
|
+
borderStyle="single"
|
|
29
|
+
borderColor={borderColor}
|
|
30
|
+
>
|
|
31
|
+
<Box paddingX={1}>
|
|
32
|
+
<Text bold={isActive} color={borderColor} dimColor={!isActive}>
|
|
33
|
+
{title}
|
|
34
|
+
</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
|
37
|
+
{children}
|
|
38
|
+
</Box>
|
|
39
|
+
</Box>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text, useStdout } from 'ink'
|
|
3
|
+
import { useTheme } from '../../theme/index'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LINE_LENGTH = 36
|
|
6
|
+
|
|
7
|
+
export function Divider({ title }: { readonly title?: string }): React.ReactElement {
|
|
8
|
+
const theme = useTheme()
|
|
9
|
+
const { stdout } = useStdout()
|
|
10
|
+
const cols = stdout?.columns ?? 80
|
|
11
|
+
const len = Math.min(DEFAULT_LINE_LENGTH, Math.max(10, cols - 8))
|
|
12
|
+
const line = '─'.repeat(len)
|
|
13
|
+
|
|
14
|
+
if (title) {
|
|
15
|
+
const pad = Math.max(0, len - title.length - 2)
|
|
16
|
+
const left = Math.floor(pad / 2)
|
|
17
|
+
const right = pad - left
|
|
18
|
+
const text = '─'.repeat(left) + ` ${title} ` + '─'.repeat(right)
|
|
19
|
+
return (
|
|
20
|
+
<Box paddingY={0}>
|
|
21
|
+
<Text color={theme.colors.border}>{text}</Text>
|
|
22
|
+
</Box>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Box paddingY={0}>
|
|
28
|
+
<Text color={theme.colors.border}>{line}</Text>
|
|
29
|
+
</Box>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { useTheme } from '../../theme/index'
|
|
4
|
+
|
|
5
|
+
interface EmptyStateProps {
|
|
6
|
+
readonly icon?: string
|
|
7
|
+
readonly message: string
|
|
8
|
+
readonly hint?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function EmptyState({
|
|
12
|
+
icon = '~',
|
|
13
|
+
message,
|
|
14
|
+
hint,
|
|
15
|
+
}: EmptyStateProps): React.ReactElement {
|
|
16
|
+
const theme = useTheme()
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Box
|
|
20
|
+
flexDirection="column"
|
|
21
|
+
alignItems="center"
|
|
22
|
+
justifyContent="center"
|
|
23
|
+
flexGrow={1}
|
|
24
|
+
paddingY={2}
|
|
25
|
+
>
|
|
26
|
+
<Text color={theme.colors.muted}>{icon}</Text>
|
|
27
|
+
<Text color={theme.colors.muted}>{message}</Text>
|
|
28
|
+
{hint && (
|
|
29
|
+
<Text color={theme.colors.muted} dimColor>
|
|
30
|
+
{hint}
|
|
31
|
+
</Text>
|
|
32
|
+
)}
|
|
33
|
+
</Box>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react'
|
|
2
|
+
import { Box, Text, useInput } from 'ink'
|
|
3
|
+
import { TextInput } from '@inkjs/ui'
|
|
4
|
+
import { useTheme } from '../../theme/index'
|
|
5
|
+
import { useInputFocus } from '../../hooks/useInputFocus'
|
|
6
|
+
import { Modal } from './Modal'
|
|
7
|
+
import type { FilterState, SortField } from '../../hooks/useFilter'
|
|
8
|
+
|
|
9
|
+
interface FilterModalProps {
|
|
10
|
+
readonly filter: FilterState
|
|
11
|
+
readonly availableRepos: readonly string[]
|
|
12
|
+
readonly availableAuthors: readonly string[]
|
|
13
|
+
readonly availableLabels: readonly string[]
|
|
14
|
+
readonly onSearchChange: (search: string) => void
|
|
15
|
+
readonly onRepoChange: (repo: string | null) => void
|
|
16
|
+
readonly onAuthorChange: (author: string | null) => void
|
|
17
|
+
readonly onLabelChange: (label: string | null) => void
|
|
18
|
+
readonly onSortChange: (sortBy: SortField) => void
|
|
19
|
+
readonly onSortDirectionToggle: () => void
|
|
20
|
+
readonly onClear: () => void
|
|
21
|
+
readonly onClose: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function FilterModal({
|
|
25
|
+
filter,
|
|
26
|
+
onSearchChange,
|
|
27
|
+
onClear,
|
|
28
|
+
onClose,
|
|
29
|
+
}: FilterModalProps): React.ReactElement {
|
|
30
|
+
const theme = useTheme()
|
|
31
|
+
const { setInputActive } = useInputFocus()
|
|
32
|
+
const [searchValue, setSearchValue] = useState(filter.search)
|
|
33
|
+
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setInputActive(true)
|
|
37
|
+
return () => setInputActive(false)
|
|
38
|
+
}, [setInputActive])
|
|
39
|
+
|
|
40
|
+
useInput((input, key) => {
|
|
41
|
+
if (showClearConfirm) {
|
|
42
|
+
if (input === 'y' || input === 'Y') {
|
|
43
|
+
onClear()
|
|
44
|
+
onClose()
|
|
45
|
+
} else if (input === 'n' || input === 'N' || key.escape) {
|
|
46
|
+
setShowClearConfirm(false)
|
|
47
|
+
}
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (key.escape) {
|
|
52
|
+
onClose()
|
|
53
|
+
} else if (key.return) {
|
|
54
|
+
onSearchChange(searchValue)
|
|
55
|
+
onClose()
|
|
56
|
+
} else if (input === 'c' && filter.search) {
|
|
57
|
+
setShowClearConfirm(true)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if (showClearConfirm) {
|
|
62
|
+
return (
|
|
63
|
+
<Modal>
|
|
64
|
+
<Box
|
|
65
|
+
flexDirection="column"
|
|
66
|
+
borderStyle="round"
|
|
67
|
+
borderColor={theme.colors.accent}
|
|
68
|
+
// @ts-ignore
|
|
69
|
+
backgroundColor={theme.colors.bg}
|
|
70
|
+
paddingX={2}
|
|
71
|
+
paddingY={1}
|
|
72
|
+
gap={1}
|
|
73
|
+
>
|
|
74
|
+
<Text color={theme.colors.accent} bold>
|
|
75
|
+
Clear all filters?
|
|
76
|
+
</Text>
|
|
77
|
+
<Text color={theme.colors.text}>y: Yes, n: No</Text>
|
|
78
|
+
</Box>
|
|
79
|
+
</Modal>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Modal>
|
|
85
|
+
<Box
|
|
86
|
+
flexDirection="column"
|
|
87
|
+
borderStyle="round"
|
|
88
|
+
borderColor={theme.colors.accent}
|
|
89
|
+
// @ts-ignore
|
|
90
|
+
backgroundColor={theme.colors.bg}
|
|
91
|
+
paddingX={2}
|
|
92
|
+
paddingY={1}
|
|
93
|
+
gap={1}
|
|
94
|
+
>
|
|
95
|
+
<Text color={theme.colors.accent} bold>
|
|
96
|
+
Search PRs
|
|
97
|
+
</Text>
|
|
98
|
+
|
|
99
|
+
<Text color={theme.colors.muted}>
|
|
100
|
+
Filter by title, number, or author
|
|
101
|
+
</Text>
|
|
102
|
+
|
|
103
|
+
<Box>
|
|
104
|
+
<TextInput
|
|
105
|
+
defaultValue={searchValue}
|
|
106
|
+
onChange={setSearchValue}
|
|
107
|
+
placeholder="Type to search..."
|
|
108
|
+
/>
|
|
109
|
+
</Box>
|
|
110
|
+
|
|
111
|
+
<Text color={theme.colors.muted} dimColor>
|
|
112
|
+
Enter: apply | Esc: cancel{filter.search ? ' | c: clear' : ''}
|
|
113
|
+
</Text>
|
|
114
|
+
</Box>
|
|
115
|
+
</Modal>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text, useStdout } from 'ink'
|
|
3
|
+
import { Spinner } from '@inkjs/ui'
|
|
4
|
+
import { useTheme } from '../../theme/index'
|
|
5
|
+
|
|
6
|
+
interface LoadingIndicatorProps {
|
|
7
|
+
readonly message?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function LoadingIndicator({
|
|
11
|
+
message = 'Loading...',
|
|
12
|
+
}: LoadingIndicatorProps): React.ReactElement {
|
|
13
|
+
const theme = useTheme()
|
|
14
|
+
const { stdout } = useStdout()
|
|
15
|
+
const height = stdout?.rows ?? 24
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Box
|
|
19
|
+
flexDirection="column"
|
|
20
|
+
justifyContent="center"
|
|
21
|
+
alignItems="center"
|
|
22
|
+
height={height - 4}
|
|
23
|
+
flexGrow={1}
|
|
24
|
+
>
|
|
25
|
+
<Box gap={1}>
|
|
26
|
+
<Spinner />
|
|
27
|
+
<Text color={theme.colors.accent}>{message}</Text>
|
|
28
|
+
</Box>
|
|
29
|
+
</Box>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, useStdout } from 'ink'
|
|
3
|
+
|
|
4
|
+
interface ModalProps {
|
|
5
|
+
readonly children: React.ReactNode
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Modal({ children }: ModalProps): React.ReactElement {
|
|
9
|
+
const { stdout } = useStdout()
|
|
10
|
+
const width = stdout?.columns ?? 80
|
|
11
|
+
const height = stdout?.rows ?? 24
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Box
|
|
15
|
+
position="absolute"
|
|
16
|
+
width={width}
|
|
17
|
+
height={height}
|
|
18
|
+
justifyContent="center"
|
|
19
|
+
alignItems="center"
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
</Box>
|
|
23
|
+
)
|
|
24
|
+
}
|