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
@@ -0,0 +1,142 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+ import { Box, Text, useStdout } from 'ink'
3
+ import { ScrollList, type ScrollListRef } from 'ink-scroll-list'
4
+ import { useTheme } from '../../theme/index'
5
+ import { useListNavigation } from '../../hooks/useListNavigation'
6
+ import type { Commit } from '../../models/commit'
7
+ import { timeAgo } from '../../utils/date'
8
+ import { EmptyState } from '../common/EmptyState'
9
+
10
+ interface CommitsTabProps {
11
+ readonly commits: readonly Commit[]
12
+ readonly isActive: boolean
13
+ }
14
+
15
+ function CommitItem({
16
+ commit,
17
+ isFocus,
18
+ }: {
19
+ readonly commit: Commit
20
+ readonly isFocus: boolean
21
+ }): React.ReactElement {
22
+ const theme = useTheme()
23
+
24
+ const shortSha = commit.sha.slice(0, 7)
25
+ const message = commit.commit.message.split('\n')[0] ?? ''
26
+ const author = commit.author?.login ?? commit.commit.author.name
27
+ const date = commit.commit.author.date
28
+
29
+ return (
30
+ <Box
31
+ paddingX={1}
32
+ paddingY={0}
33
+ gap={1}
34
+ // @ts-ignore
35
+ backgroundColor={isFocus ? theme.colors.selection : undefined}
36
+ >
37
+ <Box width={10}>
38
+ <Text color={theme.colors.warning} bold={isFocus}>
39
+ {shortSha}
40
+ </Text>
41
+ </Box>
42
+ <Box flexGrow={1} flexShrink={1}>
43
+ <Text
44
+ color={isFocus ? theme.colors.listSelectedFg : theme.colors.text}
45
+ bold={isFocus}
46
+ wrap="truncate"
47
+ >
48
+ {message}
49
+ </Text>
50
+ </Box>
51
+ <Box width={16}>
52
+ <Text color={theme.colors.secondary}>{author}</Text>
53
+ </Box>
54
+ <Box width={14}>
55
+ <Text color={theme.colors.muted}>{timeAgo(date)}</Text>
56
+ </Box>
57
+ </Box>
58
+ )
59
+ }
60
+
61
+ export function CommitsTab({
62
+ commits,
63
+ isActive,
64
+ }: CommitsTabProps): React.ReactElement {
65
+ const { stdout } = useStdout()
66
+ const theme = useTheme()
67
+ const viewportHeight = Math.max(1, (stdout?.rows ?? 24) - 10)
68
+
69
+ const listRef = useRef<ScrollListRef>(null)
70
+ const { selectedIndex } = useListNavigation({
71
+ itemCount: commits.length,
72
+ viewportHeight,
73
+ isActive,
74
+ })
75
+
76
+ useEffect(() => {
77
+ const handleResize = (): void => listRef.current?.remeasure()
78
+ stdout?.on('resize', handleResize)
79
+ return () => {
80
+ stdout?.off('resize', handleResize)
81
+ }
82
+ }, [stdout])
83
+
84
+ if (commits.length === 0) {
85
+ return <EmptyState message="No commits found" />
86
+ }
87
+
88
+ return (
89
+ <Box flexDirection="column" flexGrow={1}>
90
+ <Box paddingX={1} paddingY={1} gap={1}>
91
+ <Text color={theme.colors.accent} bold>
92
+ Commits
93
+ </Text>
94
+ <Text color={theme.colors.muted}>({commits.length})</Text>
95
+ </Box>
96
+
97
+ <Box
98
+ paddingX={1}
99
+ paddingBottom={1}
100
+ gap={1}
101
+ borderStyle="single"
102
+ borderColor={theme.colors.border}
103
+ borderTop={false}
104
+ borderLeft={false}
105
+ borderRight={false}
106
+ >
107
+ <Box width={10}>
108
+ <Text color={theme.colors.muted} bold>
109
+ SHA
110
+ </Text>
111
+ </Box>
112
+ <Box flexGrow={1}>
113
+ <Text color={theme.colors.muted} bold>
114
+ Message
115
+ </Text>
116
+ </Box>
117
+ <Box width={16}>
118
+ <Text color={theme.colors.muted} bold>
119
+ Author
120
+ </Text>
121
+ </Box>
122
+ <Box width={14}>
123
+ <Text color={theme.colors.muted} bold>
124
+ Date
125
+ </Text>
126
+ </Box>
127
+ </Box>
128
+
129
+ <Box flexDirection="column" overflow="hidden" height={viewportHeight}>
130
+ <ScrollList ref={listRef} selectedIndex={selectedIndex} scrollAlignment="auto">
131
+ {commits.map((commit, index) => (
132
+ <CommitItem
133
+ key={commit.sha}
134
+ commit={commit}
135
+ isFocus={index === selectedIndex}
136
+ />
137
+ ))}
138
+ </ScrollList>
139
+ </Box>
140
+ </Box>
141
+ )
142
+ }
@@ -0,0 +1,273 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+ import { Box, Text, useStdout } from 'ink'
3
+ import { Match } from 'effect'
4
+ import { ScrollList, type ScrollListRef } from 'ink-scroll-list'
5
+ import { useTheme } from '../../theme/index'
6
+ import { Divider } from '../common/Divider'
7
+ import { useListNavigation } from '../../hooks/useListNavigation'
8
+ import type { PullRequest } from '../../models/pull-request'
9
+ import type { Comment } from '../../models/comment'
10
+ import type { Review } from '../../models/review'
11
+ import { timeAgo } from '../../utils/date'
12
+
13
+ interface ConversationsTabProps {
14
+ readonly pr: PullRequest
15
+ readonly comments: readonly Comment[]
16
+ readonly reviews: readonly Review[]
17
+ readonly isActive: boolean
18
+ }
19
+
20
+ interface TimelineItem {
21
+ readonly id: string
22
+ readonly type: 'description' | 'review' | 'comment'
23
+ readonly user: string
24
+ readonly body: string | null
25
+ readonly date: string
26
+ readonly state?: string
27
+ readonly path?: string
28
+ readonly line?: number | null
29
+ }
30
+
31
+ function buildTimeline(
32
+ pr: PullRequest,
33
+ comments: readonly Comment[],
34
+ reviews: readonly Review[],
35
+ ): TimelineItem[] {
36
+ const items: TimelineItem[] = []
37
+
38
+ // Add PR description first
39
+ items.push({
40
+ id: 'description',
41
+ type: 'description',
42
+ user: pr.user.login,
43
+ body: pr.body,
44
+ date: pr.created_at,
45
+ })
46
+
47
+ // Add reviews
48
+ for (const review of reviews) {
49
+ if (review.state !== 'PENDING') {
50
+ items.push({
51
+ id: `review-${review.id}`,
52
+ type: 'review',
53
+ user: review.user.login,
54
+ body: review.body,
55
+ date: review.submitted_at ?? pr.created_at,
56
+ state: review.state,
57
+ })
58
+ }
59
+ }
60
+
61
+ // Add comments
62
+ for (const comment of comments) {
63
+ items.push({
64
+ id: `comment-${comment.id}`,
65
+ type: 'comment',
66
+ user: comment.user.login,
67
+ body: comment.body,
68
+ date: comment.created_at,
69
+ path: comment.path,
70
+ line: comment.line,
71
+ })
72
+ }
73
+
74
+ // Sort by date
75
+ items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
76
+
77
+ return items
78
+ }
79
+
80
+ function TimelineItemView({
81
+ item,
82
+ isFocus,
83
+ }: {
84
+ readonly item: TimelineItem
85
+ readonly isFocus: boolean
86
+ }): React.ReactElement {
87
+ const theme = useTheme()
88
+
89
+ const getStateIcon = (state?: string): { icon: string; color: string } =>
90
+ Match.value(state).pipe(
91
+ Match.when('APPROVED', () => ({
92
+ icon: '✓',
93
+ color: theme.colors.success,
94
+ })),
95
+ Match.when('CHANGES_REQUESTED', () => ({
96
+ icon: '✗',
97
+ color: theme.colors.error,
98
+ })),
99
+ Match.when('COMMENTED', () => ({ icon: '💬', color: theme.colors.info })),
100
+ Match.when('DISMISSED', () => ({ icon: '—', color: theme.colors.muted })),
101
+ Match.orElse(() => ({ icon: '•', color: theme.colors.muted })),
102
+ )
103
+
104
+ const { icon, color } =
105
+ item.type === 'review'
106
+ ? getStateIcon(item.state)
107
+ : item.type === 'description'
108
+ ? { icon: '📝', color: theme.colors.accent }
109
+ : { icon: '💬', color: theme.colors.info }
110
+
111
+ const stateLabel =
112
+ item.type === 'review' && item.state
113
+ ? item.state.toLowerCase().replace('_', ' ')
114
+ : ''
115
+ const location =
116
+ item.type === 'comment' && item.path
117
+ ? ` on ${item.path}${item.line != null ? `:${item.line}` : ''}`
118
+ : ''
119
+
120
+ return (
121
+ <Box
122
+ flexDirection="column"
123
+ paddingX={1}
124
+ paddingY={1}
125
+ marginBottom={2}
126
+ gap={1}
127
+ >
128
+ <Box flexDirection="row">
129
+ {isFocus && <Text color={theme.colors.accent}>{'▸ '}</Text>}
130
+ <Text color={color}>{icon}</Text>
131
+ <Text> </Text>
132
+ <Text color={theme.colors.secondary} bold>
133
+ {item.user}
134
+ </Text>
135
+ {stateLabel ? (
136
+ <>
137
+ <Text> </Text>
138
+ <Text color={color}>{stateLabel}</Text>
139
+ </>
140
+ ) : null}
141
+ {location ? <Text color={theme.colors.muted}>{location}</Text> : null}
142
+ <Text color={theme.colors.muted}> · {timeAgo(item.date)}</Text>
143
+ </Box>
144
+ {item.body ? (
145
+ <Box paddingLeft={isFocus ? 3 : 2} marginTop={0} width="80%">
146
+ <Text color={theme.colors.text} wrap="wrap">
147
+ {item.body}
148
+ </Text>
149
+ </Box>
150
+ ) : null}
151
+ </Box>
152
+ )
153
+ }
154
+
155
+ function PRInfoSection({
156
+ pr,
157
+ }: {
158
+ readonly pr: PullRequest
159
+ }): React.ReactElement {
160
+ const theme = useTheme()
161
+
162
+ return (
163
+ <Box
164
+ flexDirection="column"
165
+ paddingX={1}
166
+ paddingY={1}
167
+ borderStyle="single"
168
+ borderColor={theme.colors.border}
169
+ >
170
+ <Box flexDirection="row">
171
+ <Text color={theme.colors.muted}>Author: </Text>
172
+ <Text color={theme.colors.secondary} bold>
173
+ {pr.user.login}
174
+ </Text>
175
+ </Box>
176
+ {pr.requested_reviewers.length > 0 ? (
177
+ <Box flexDirection="row" marginTop={0}>
178
+ <Text color={theme.colors.muted}>Reviewers: </Text>
179
+ <Text color={theme.colors.text}>
180
+ {pr.requested_reviewers.map((r) => r.login).join(', ')}
181
+ </Text>
182
+ </Box>
183
+ ) : null}
184
+ {pr.labels.length > 0 ? (
185
+ <Box flexDirection="row" marginTop={0}>
186
+ <Text color={theme.colors.muted}>Labels: </Text>
187
+ {pr.labels.map((label) => (
188
+ <Text key={label.id} color={`#${label.color}`}>
189
+ [{label.name}]{' '}
190
+ </Text>
191
+ ))}
192
+ </Box>
193
+ ) : null}
194
+ <Box paddingY={0}>
195
+ <Divider />
196
+ </Box>
197
+ <Box flexDirection="row" marginTop={0}>
198
+ <Text color={theme.colors.diffAdd}>+{pr.additions}</Text>
199
+ <Text> </Text>
200
+ <Text color={theme.colors.diffDel}>-{pr.deletions}</Text>
201
+ <Text color={theme.colors.muted}>
202
+ {' '}
203
+ {pr.changed_files} files changed
204
+ </Text>
205
+ </Box>
206
+ </Box>
207
+ )
208
+ }
209
+
210
+ const CONVERSATIONS_RESERVED_LINES = 18
211
+
212
+ export function ConversationsTab({
213
+ pr,
214
+ comments,
215
+ reviews,
216
+ isActive,
217
+ }: ConversationsTabProps): React.ReactElement {
218
+ const theme = useTheme()
219
+ const { stdout } = useStdout()
220
+ const listRef = useRef<ScrollListRef>(null)
221
+ const timeline = buildTimeline(pr, comments, reviews)
222
+ const viewportHeight = Math.max(1, (stdout?.rows ?? 24) - CONVERSATIONS_RESERVED_LINES)
223
+
224
+ const { selectedIndex } = useListNavigation({
225
+ itemCount: timeline.length,
226
+ viewportHeight,
227
+ isActive,
228
+ })
229
+
230
+ useEffect(() => {
231
+ const handleResize = (): void => {
232
+ listRef.current?.remeasure()
233
+ }
234
+ stdout?.on('resize', handleResize)
235
+ return () => {
236
+ stdout?.off('resize', handleResize)
237
+ }
238
+ }, [stdout])
239
+
240
+ return (
241
+ <Box flexDirection="column" flexGrow={1}>
242
+ <PRInfoSection pr={pr} />
243
+
244
+ <Box flexDirection="row" paddingX={1} paddingY={0} marginBottom={1}>
245
+ <Text color={theme.colors.accent} bold>
246
+ Timeline ({timeline.length} items)
247
+ </Text>
248
+ </Box>
249
+
250
+ <Box flexDirection="column" flexGrow={1} overflow="hidden" height={viewportHeight}>
251
+ {timeline.length === 0 ? (
252
+ <Box paddingX={1}>
253
+ <Text color={theme.colors.muted}>No conversations yet</Text>
254
+ </Box>
255
+ ) : (
256
+ <ScrollList
257
+ ref={listRef}
258
+ selectedIndex={selectedIndex}
259
+ scrollAlignment="auto"
260
+ >
261
+ {timeline.map((item, index) => (
262
+ <TimelineItemView
263
+ key={item.id}
264
+ item={item}
265
+ isFocus={index === selectedIndex}
266
+ />
267
+ ))}
268
+ </ScrollList>
269
+ )}
270
+ </Box>
271
+ </Box>
272
+ )
273
+ }