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
|
@@ -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
|
+
}
|