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.
Files changed (78) hide show
  1. package/.github/workflows/publish-npm.yml +51 -0
  2. package/.prettierrc +7 -0
  3. package/CLAUDE.md +50 -0
  4. package/README.md +119 -0
  5. package/dist/cli.js +3830 -0
  6. package/dist/cli.js.map +1 -0
  7. package/package.json +64 -0
  8. package/pnpm-workspace.yaml +5 -0
  9. package/src/app.tsx +235 -0
  10. package/src/cli.tsx +78 -0
  11. package/src/components/common/BorderedBox.tsx +41 -0
  12. package/src/components/common/Divider.tsx +31 -0
  13. package/src/components/common/EmptyState.tsx +35 -0
  14. package/src/components/common/FilterModal.tsx +117 -0
  15. package/src/components/common/LoadingIndicator.tsx +31 -0
  16. package/src/components/common/Modal.tsx +24 -0
  17. package/src/components/common/PaginationBar.tsx +56 -0
  18. package/src/components/common/SortModal.tsx +91 -0
  19. package/src/components/common/Spinner.tsx +28 -0
  20. package/src/components/common/index.ts +9 -0
  21. package/src/components/layout/HelpModal.tsx +61 -0
  22. package/src/components/layout/MainPanel.tsx +26 -0
  23. package/src/components/layout/Sidebar.tsx +71 -0
  24. package/src/components/layout/StatusBar.tsx +42 -0
  25. package/src/components/layout/TokenInputModal.tsx +92 -0
  26. package/src/components/layout/TopBar.tsx +44 -0
  27. package/src/components/layout/index.ts +6 -0
  28. package/src/components/pr/CommentsTab.tsx +92 -0
  29. package/src/components/pr/CommitsTab.tsx +142 -0
  30. package/src/components/pr/ConversationsTab.tsx +273 -0
  31. package/src/components/pr/FilesTab.tsx +532 -0
  32. package/src/components/pr/PRHeader.tsx +69 -0
  33. package/src/components/pr/PRListItem.tsx +92 -0
  34. package/src/components/pr/PRTabs.tsx +50 -0
  35. package/src/components/pr/index.ts +8 -0
  36. package/src/hooks/index.ts +12 -0
  37. package/src/hooks/useActivePanel.ts +54 -0
  38. package/src/hooks/useAppKeymap.ts +22 -0
  39. package/src/hooks/useAuth.ts +131 -0
  40. package/src/hooks/useConfig.ts +52 -0
  41. package/src/hooks/useFilter.ts +192 -0
  42. package/src/hooks/useGitHub.ts +260 -0
  43. package/src/hooks/useInputFocus.tsx +35 -0
  44. package/src/hooks/useListNavigation.ts +121 -0
  45. package/src/hooks/useLoading.ts +25 -0
  46. package/src/hooks/usePagination.ts +87 -0
  47. package/src/models/comment.ts +15 -0
  48. package/src/models/commit.ts +20 -0
  49. package/src/models/diff.ts +93 -0
  50. package/src/models/errors.ts +24 -0
  51. package/src/models/file-change.ts +22 -0
  52. package/src/models/index.ts +12 -0
  53. package/src/models/pull-request.ts +40 -0
  54. package/src/models/review.ts +17 -0
  55. package/src/models/user.ts +9 -0
  56. package/src/screens/InvolvedScreen.tsx +161 -0
  57. package/src/screens/MyPRsScreen.tsx +161 -0
  58. package/src/screens/PRDetailScreen.tsx +88 -0
  59. package/src/screens/PRListScreen.tsx +96 -0
  60. package/src/screens/ReviewRequestsScreen.tsx +161 -0
  61. package/src/screens/SettingsScreen.tsx +284 -0
  62. package/src/screens/ThisRepoScreen.tsx +175 -0
  63. package/src/screens/index.ts +7 -0
  64. package/src/services/Auth.ts +314 -0
  65. package/src/services/Config.ts +81 -0
  66. package/src/services/GitHubApi.ts +262 -0
  67. package/src/services/Loading.ts +54 -0
  68. package/src/services/index.ts +27 -0
  69. package/src/theme/index.ts +28 -0
  70. package/src/theme/themes.ts +84 -0
  71. package/src/theme/types.ts +28 -0
  72. package/src/utils/date.ts +28 -0
  73. package/src/utils/git.ts +67 -0
  74. package/src/utils/index.ts +2 -0
  75. package/src/utils/terminal.ts +25 -0
  76. package/tsconfig.json +24 -0
  77. package/tsup.config.ts +13 -0
  78. 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
+ }
@@ -0,0 +1,5 @@
1
+ packages:
2
+ - '.'
3
+
4
+ ignoredBuiltDependencies:
5
+ - esbuild
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
+ }