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,532 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Box, Text, useInput, useStdout } from 'ink'
3
+ import { UnorderedList } from '@inkjs/ui'
4
+ import { ScrollList, type ScrollListRef } from 'ink-scroll-list'
5
+ import SyntaxHighlight from 'ink-syntax-highlight'
6
+ import { useTheme } from '../../theme/index'
7
+ import { useListNavigation } from '../../hooks/useListNavigation'
8
+ import type { FileChange } from '../../models/file-change'
9
+ import type { Hunk, DiffLine } from '../../models/diff'
10
+ import { parseDiffPatch } from '../../models/diff'
11
+ import { EmptyState } from '../common/EmptyState'
12
+
13
+ type TreeNode =
14
+ | { type: 'dir'; name: string; children: TreeNode[] }
15
+ | { type: 'file'; file: FileChange }
16
+
17
+ interface DirNode {
18
+ dirs: Record<string, DirNode>
19
+ files: FileChange[]
20
+ }
21
+
22
+ function buildFileTree(files: readonly FileChange[]): TreeNode[] {
23
+ const root: DirNode = { dirs: {}, files: [] }
24
+ for (const file of files) {
25
+ const parts = file.filename.split('/')
26
+ let current = root
27
+ for (let i = 0; i < parts.length - 1; i++) {
28
+ const segment = parts[i]!
29
+ if (!current.dirs[segment]) {
30
+ current.dirs[segment] = { dirs: {}, files: [] }
31
+ }
32
+ current = current.dirs[segment]
33
+ }
34
+ const leafName = parts[parts.length - 1] ?? file.filename
35
+ current.files.push(file)
36
+ }
37
+
38
+ function toTree(node: DirNode): TreeNode[] {
39
+ const result: TreeNode[] = []
40
+ const dirNames = Object.keys(node.dirs).sort((a, b) => a.localeCompare(b))
41
+ const files = [...node.files].sort((a, b) =>
42
+ a.filename.localeCompare(b.filename),
43
+ )
44
+ for (const name of dirNames) {
45
+ result.push({
46
+ type: 'dir',
47
+ name,
48
+ children: toTree(node.dirs[name]!),
49
+ })
50
+ }
51
+ for (const file of files) {
52
+ result.push({ type: 'file', file })
53
+ }
54
+ return result
55
+ }
56
+
57
+ return toTree(root)
58
+ }
59
+
60
+ function flattenTreeToFiles(nodes: TreeNode[]): FileChange[] {
61
+ const out: FileChange[] = []
62
+ function walk(n: TreeNode[]) {
63
+ for (const node of n) {
64
+ if (node.type === 'file') out.push(node.file)
65
+ else walk(node.children)
66
+ }
67
+ }
68
+ walk(nodes)
69
+ return out
70
+ }
71
+
72
+ type DisplayRow =
73
+ | { indent: number; type: 'dir'; name: string }
74
+ | {
75
+ indent: number
76
+ type: 'file'
77
+ name: string
78
+ file: FileChange
79
+ fileIndex: number
80
+ }
81
+
82
+ function buildDisplayRows(
83
+ nodes: TreeNode[],
84
+ indent = 0,
85
+ fileIndexRef: { current: number },
86
+ ): DisplayRow[] {
87
+ const rows: DisplayRow[] = []
88
+ for (const node of nodes) {
89
+ if (node.type === 'file') {
90
+ const parts = node.file.filename.split('/')
91
+ const name = parts[parts.length - 1] ?? node.file.filename
92
+ rows.push({
93
+ indent,
94
+ type: 'file',
95
+ name,
96
+ file: node.file,
97
+ fileIndex: fileIndexRef.current,
98
+ })
99
+ fileIndexRef.current += 1
100
+ } else {
101
+ rows.push({ indent, type: 'dir', name: node.name })
102
+ rows.push(...buildDisplayRows(node.children, indent + 1, fileIndexRef))
103
+ }
104
+ }
105
+ return rows
106
+ }
107
+
108
+
109
+ function getLanguageFromFilename(filename: string): string | undefined {
110
+ const ext = filename.split('.').pop()?.toLowerCase()
111
+ const map: Record<string, string> = {
112
+ ts: 'typescript',
113
+ tsx: 'typescript',
114
+ js: 'javascript',
115
+ jsx: 'javascript',
116
+ json: 'json',
117
+ md: 'markdown',
118
+ py: 'python',
119
+ go: 'go',
120
+ rs: 'rust',
121
+ css: 'css',
122
+ scss: 'scss',
123
+ html: 'html',
124
+ yaml: 'yaml',
125
+ yml: 'yaml',
126
+ }
127
+ return ext ? map[ext] : undefined
128
+ }
129
+
130
+ interface FilesTabProps {
131
+ readonly files: readonly FileChange[]
132
+ readonly isActive: boolean
133
+ }
134
+
135
+ type FocusPanel = 'tree' | 'diff'
136
+
137
+ interface FileItemProps {
138
+ readonly item: FileChange
139
+ readonly isFocus: boolean
140
+ readonly isSelected: boolean
141
+ }
142
+
143
+ function FileItem({
144
+ item,
145
+ isFocus,
146
+ isSelected,
147
+ }: FileItemProps): React.ReactElement {
148
+ const theme = useTheme()
149
+
150
+ const statusColor =
151
+ item.status === 'added'
152
+ ? theme.colors.diffAdd
153
+ : item.status === 'removed'
154
+ ? theme.colors.diffDel
155
+ : theme.colors.warning
156
+
157
+ const statusIcon =
158
+ item.status === 'added'
159
+ ? 'A'
160
+ : item.status === 'removed'
161
+ ? 'D'
162
+ : item.status === 'renamed'
163
+ ? 'R'
164
+ : 'M'
165
+
166
+ const parts = item.filename.split('/')
167
+ const filename = parts[parts.length - 1] ?? item.filename
168
+
169
+ return (
170
+ <Box paddingX={0} gap={1} width="100%">
171
+ <Text color={statusColor} bold>
172
+ {statusIcon}
173
+ </Text>
174
+ <Text
175
+ color={
176
+ isFocus
177
+ ? theme.colors.listSelectedFg
178
+ : isSelected
179
+ ? theme.colors.accent
180
+ : theme.colors.text
181
+ }
182
+ bold={isFocus || isSelected}
183
+ inverse={isFocus}
184
+ >
185
+ {filename}
186
+ </Text>
187
+ </Box>
188
+ )
189
+ }
190
+
191
+ interface FileTreeProps {
192
+ readonly nodes: TreeNode[]
193
+ readonly fileIndexRef: { current: number }
194
+ readonly treeSelectedIndex: number
195
+ readonly selectedFileIndex: number
196
+ readonly isPanelFocused: boolean
197
+ }
198
+
199
+ function FileTree({
200
+ nodes,
201
+ fileIndexRef,
202
+ treeSelectedIndex,
203
+ selectedFileIndex,
204
+ isPanelFocused,
205
+ }: FileTreeProps): React.ReactElement {
206
+ const theme = useTheme()
207
+ return (
208
+ <UnorderedList>
209
+ {nodes.map((node) => {
210
+ if (node.type === 'file') {
211
+ const idx = fileIndexRef.current
212
+ fileIndexRef.current += 1
213
+ const isFocus = isPanelFocused && idx === treeSelectedIndex
214
+ const isSelected = idx === selectedFileIndex
215
+ return (
216
+ <UnorderedList.Item key={node.file.filename}>
217
+ <FileItem
218
+ item={node.file}
219
+ isFocus={isFocus}
220
+ isSelected={isSelected}
221
+ />
222
+ </UnorderedList.Item>
223
+ )
224
+ }
225
+ return (
226
+ <UnorderedList.Item key={node.name}>
227
+ <Text color={theme.colors.muted}>{node.name}/</Text>
228
+ <FileTree
229
+ nodes={node.children}
230
+ fileIndexRef={fileIndexRef}
231
+ treeSelectedIndex={treeSelectedIndex}
232
+ selectedFileIndex={selectedFileIndex}
233
+ isPanelFocused={isPanelFocused}
234
+ />
235
+ </UnorderedList.Item>
236
+ )
237
+ })}
238
+ </UnorderedList>
239
+ )
240
+ }
241
+
242
+ interface DiffLineViewProps {
243
+ readonly line: DiffLine
244
+ readonly lineNumber: number
245
+ readonly isFocus: boolean
246
+ readonly language?: string
247
+ }
248
+
249
+ function DiffLineView({
250
+ line,
251
+ lineNumber,
252
+ isFocus,
253
+ language,
254
+ }: DiffLineViewProps): React.ReactElement {
255
+ const theme = useTheme()
256
+
257
+ const bgColor = isFocus ? theme.colors.selection : undefined
258
+
259
+ const textColor =
260
+ line.type === 'add'
261
+ ? theme.colors.diffAdd
262
+ : line.type === 'del'
263
+ ? theme.colors.diffDel
264
+ : line.type === 'header'
265
+ ? theme.colors.info
266
+ : theme.colors.text
267
+
268
+ const prefix =
269
+ line.type === 'add'
270
+ ? '+'
271
+ : line.type === 'del'
272
+ ? '-'
273
+ : line.type === 'header'
274
+ ? ''
275
+ : ' '
276
+
277
+ const useSyntaxHighlight =
278
+ line.type === 'context' && language && line.content.trim().length > 0
279
+
280
+ return (
281
+ // @ts-ignore
282
+ <Box backgroundColor={bgColor}>
283
+ <Box width={5}>
284
+ <Text color={theme.colors.muted}>
285
+ {line.type === 'header' ? '' : String(lineNumber).padStart(4, ' ')}
286
+ </Text>
287
+ </Box>
288
+ {useSyntaxHighlight ? (
289
+ <Box flexDirection="row">
290
+ <Text color={theme.colors.text}>{prefix}</Text>
291
+ <SyntaxHighlight code={line.content} language={language} />
292
+ </Box>
293
+ ) : (
294
+ <Text color={textColor} bold={isFocus} inverse={isFocus}>
295
+ {prefix}
296
+ {line.content}
297
+ </Text>
298
+ )}
299
+ </Box>
300
+ )
301
+ }
302
+
303
+ interface DiffViewProps {
304
+ readonly hunks: readonly Hunk[]
305
+ readonly selectedLine: number
306
+ readonly scrollOffset: number
307
+ readonly viewportHeight: number
308
+ readonly isActive: boolean
309
+ readonly filename?: string
310
+ }
311
+
312
+ function DiffView({
313
+ hunks,
314
+ selectedLine,
315
+ scrollOffset,
316
+ viewportHeight,
317
+ isActive,
318
+ filename,
319
+ }: DiffViewProps): React.ReactElement {
320
+ const language = filename ? getLanguageFromFilename(filename) : undefined
321
+ const theme = useTheme()
322
+
323
+ if (hunks.length === 0) {
324
+ return (
325
+ <Box paddingX={1}>
326
+ <Text color={theme.colors.muted}>No diff available</Text>
327
+ </Box>
328
+ )
329
+ }
330
+
331
+ // Flatten all lines with line numbers
332
+ const allLines: { line: DiffLine; lineNumber: number; hunkIndex: number }[] =
333
+ []
334
+ let lineNumber = 1
335
+
336
+ for (let hunkIndex = 0; hunkIndex < hunks.length; hunkIndex++) {
337
+ const hunk = hunks[hunkIndex]
338
+ for (const line of hunk.lines) {
339
+ allLines.push({ line, lineNumber, hunkIndex })
340
+ if (line.type !== 'header') {
341
+ lineNumber++
342
+ }
343
+ }
344
+ }
345
+
346
+ const visibleLines = allLines.slice(
347
+ scrollOffset,
348
+ scrollOffset + viewportHeight,
349
+ )
350
+
351
+ return (
352
+ <Box flexDirection="column" flexGrow={1}>
353
+ {visibleLines.map((item, index) => (
354
+ <DiffLineView
355
+ key={`${item.hunkIndex}-${scrollOffset + index}`}
356
+ line={item.line}
357
+ lineNumber={item.lineNumber}
358
+ isFocus={isActive && scrollOffset + index === selectedLine}
359
+ language={language}
360
+ />
361
+ ))}
362
+ </Box>
363
+ )
364
+ }
365
+
366
+ export function FilesTab({
367
+ files,
368
+ isActive,
369
+ }: FilesTabProps): React.ReactElement {
370
+ const { stdout } = useStdout()
371
+ const theme = useTheme()
372
+ const viewportHeight = Math.max(1, (stdout?.rows ?? 24) - 10)
373
+
374
+ const [focusPanel, setFocusPanel] = useState<FocusPanel>('tree')
375
+ const [selectedFileIndex, setSelectedFileIndex] = useState(0)
376
+
377
+ const tree = useMemo(() => buildFileTree(files), [files])
378
+ const fileOrder = useMemo(() => flattenTreeToFiles(tree), [tree])
379
+ const displayRows = useMemo(
380
+ () => buildDisplayRows(tree, 0, { current: 0 }),
381
+ [tree],
382
+ )
383
+
384
+ const { selectedIndex: treeSelectedIndex } = useListNavigation({
385
+ itemCount: fileOrder.length,
386
+ viewportHeight,
387
+ isActive: isActive && focusPanel === 'tree',
388
+ })
389
+
390
+ const treeViewportHeight = viewportHeight - 2
391
+ const fileTreeListRef = useRef<ScrollListRef>(null)
392
+ const selectedRowIndex = displayRows.findIndex(
393
+ (r) => r.type === 'file' && r.fileIndex === treeSelectedIndex,
394
+ )
395
+ const effectiveRowIndex = selectedRowIndex >= 0 ? selectedRowIndex : 0
396
+
397
+ useEffect(() => {
398
+ const handleResize = (): void => fileTreeListRef.current?.remeasure()
399
+ stdout?.on('resize', handleResize)
400
+ return () => {
401
+ stdout?.off('resize', handleResize)
402
+ }
403
+ }, [stdout])
404
+
405
+ React.useEffect(() => {
406
+ if (focusPanel === 'tree') {
407
+ setSelectedFileIndex(treeSelectedIndex)
408
+ }
409
+ }, [treeSelectedIndex, focusPanel])
410
+
411
+ const selectedFile = fileOrder[selectedFileIndex] ?? fileOrder[0] ?? null
412
+ const hunks = selectedFile?.patch ? parseDiffPatch(selectedFile.patch) : []
413
+
414
+ const totalDiffLines = hunks.reduce((sum, hunk) => sum + hunk.lines.length, 0)
415
+
416
+ const { selectedIndex: diffSelectedLine, scrollOffset: diffScrollOffset } =
417
+ useListNavigation({
418
+ itemCount: totalDiffLines,
419
+ viewportHeight,
420
+ isActive: isActive && focusPanel === 'diff',
421
+ })
422
+
423
+ useInput(
424
+ (input, key) => {
425
+ if (key.tab) {
426
+ setFocusPanel((prev) => (prev === 'tree' ? 'diff' : 'tree'))
427
+ } else if (input === 'h' || key.leftArrow) {
428
+ setFocusPanel('tree')
429
+ } else if (input === 'l' || key.rightArrow) {
430
+ setFocusPanel('diff')
431
+ }
432
+ },
433
+ { isActive },
434
+ )
435
+
436
+ if (files.length === 0) {
437
+ return <EmptyState message="No files changed" />
438
+ }
439
+
440
+ const isPanelFocused = focusPanel === 'tree' && isActive
441
+
442
+ return (
443
+ <Box flexDirection="row" flexGrow={1}>
444
+ <Box
445
+ flexDirection="column"
446
+ width="30%"
447
+ borderStyle="single"
448
+ borderColor={
449
+ focusPanel === 'tree' && isActive
450
+ ? theme.colors.accent
451
+ : theme.colors.border
452
+ }
453
+ >
454
+ <Box paddingX={1} paddingY={0}>
455
+ <Text color={theme.colors.accent} bold>
456
+ Files ({files.length})
457
+ </Text>
458
+ </Box>
459
+ <Box
460
+ flexDirection="column"
461
+ paddingX={1}
462
+ overflow="hidden"
463
+ height={treeViewportHeight}
464
+ minHeight={treeViewportHeight}
465
+ flexShrink={0}
466
+ >
467
+ <ScrollList
468
+ ref={fileTreeListRef}
469
+ selectedIndex={effectiveRowIndex}
470
+ scrollAlignment="auto"
471
+ >
472
+ {displayRows.map((row, rowIndex) =>
473
+ row.type === 'dir' ? (
474
+ <Box key={`row-${rowIndex}`} paddingLeft={row.indent * 2}>
475
+ <Text color={theme.colors.muted}>{row.name}/</Text>
476
+ </Box>
477
+ ) : (
478
+ <Box key={`row-${rowIndex}`} paddingLeft={row.indent * 2}>
479
+ <FileItem
480
+ item={row.file}
481
+ isFocus={
482
+ isPanelFocused && row.fileIndex === treeSelectedIndex
483
+ }
484
+ isSelected={row.fileIndex === selectedFileIndex}
485
+ />
486
+ </Box>
487
+ ),
488
+ )}
489
+ </ScrollList>
490
+ </Box>
491
+ </Box>
492
+
493
+ {/* Diff panel */}
494
+ <Box
495
+ flexDirection="column"
496
+ flexGrow={1}
497
+ borderStyle="single"
498
+ borderColor={
499
+ focusPanel === 'diff' && isActive
500
+ ? theme.colors.accent
501
+ : theme.colors.border
502
+ }
503
+ >
504
+ <Box paddingX={1} paddingY={0} gap={2}>
505
+ <Text color={theme.colors.accent} bold>
506
+ {selectedFile?.filename ?? 'No file selected'}
507
+ </Text>
508
+ {selectedFile && (
509
+ <Box gap={1}>
510
+ <Text color={theme.colors.diffAdd}>
511
+ +{selectedFile.additions}
512
+ </Text>
513
+ <Text color={theme.colors.diffDel}>
514
+ -{selectedFile.deletions}
515
+ </Text>
516
+ </Box>
517
+ )}
518
+ </Box>
519
+ <Box flexDirection="column" flexGrow={1} overflowY="hidden">
520
+ <DiffView
521
+ hunks={hunks}
522
+ selectedLine={diffSelectedLine}
523
+ scrollOffset={diffScrollOffset}
524
+ viewportHeight={viewportHeight - 2}
525
+ isActive={isActive && focusPanel === 'diff'}
526
+ filename={selectedFile?.filename}
527
+ />
528
+ </Box>
529
+ </Box>
530
+ </Box>
531
+ )
532
+ }
@@ -0,0 +1,69 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { useTheme } from '../../theme/index'
4
+ import type { PullRequest } from '../../models/pull-request'
5
+ import { timeAgo } from '../../utils/date'
6
+
7
+ interface PRHeaderProps {
8
+ readonly pr: PullRequest
9
+ }
10
+
11
+ export function PRHeader({ pr }: PRHeaderProps): React.ReactElement {
12
+ const theme = useTheme()
13
+
14
+ const stateColor = pr.draft
15
+ ? theme.colors.muted
16
+ : pr.state === 'open'
17
+ ? theme.colors.success
18
+ : theme.colors.error
19
+
20
+ const stateLabel = pr.draft
21
+ ? 'Draft'
22
+ : pr.merged
23
+ ? 'Merged'
24
+ : pr.state === 'open'
25
+ ? 'Open'
26
+ : 'Closed'
27
+
28
+ return (
29
+ <Box flexDirection="column" paddingX={1} paddingY={1}>
30
+ <Box gap={1}>
31
+ <Text color={stateColor} bold>
32
+ [{stateLabel}]
33
+ </Text>
34
+ <Text color={theme.colors.accent} bold>
35
+ #{pr.number}
36
+ </Text>
37
+ <Text color={theme.colors.text} bold>
38
+ {pr.title}
39
+ </Text>
40
+ </Box>
41
+ <Box gap={1} paddingLeft={2}>
42
+ <Text color={theme.colors.secondary}>{pr.user.login}</Text>
43
+ <Text color={theme.colors.muted}>wants to merge</Text>
44
+ <Text color={theme.colors.info}>{pr.head.ref}</Text>
45
+ <Text color={theme.colors.muted}>into</Text>
46
+ <Text color={theme.colors.info}>{pr.base.ref}</Text>
47
+ </Box>
48
+ <Box gap={2} paddingLeft={2}>
49
+ <Text color={theme.colors.muted}>
50
+ opened {timeAgo(pr.created_at)}
51
+ </Text>
52
+ <Text color={theme.colors.diffAdd}>+{pr.additions}</Text>
53
+ <Text color={theme.colors.diffDel}>-{pr.deletions}</Text>
54
+ <Text color={theme.colors.muted}>
55
+ {pr.changed_files} files changed
56
+ </Text>
57
+ </Box>
58
+ {pr.labels.length > 0 && (
59
+ <Box gap={1} paddingLeft={2}>
60
+ {pr.labels.map((label) => (
61
+ <Text key={label.id} color={`#${label.color}`}>
62
+ [{label.name}]
63
+ </Text>
64
+ ))}
65
+ </Box>
66
+ )}
67
+ </Box>
68
+ )
69
+ }
@@ -0,0 +1,92 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { useTheme } from '../../theme/index'
4
+ import type { PullRequest } from '../../models/pull-request'
5
+ import { timeAgo } from '../../utils/date'
6
+
7
+ interface PRListItemProps {
8
+ readonly item: PullRequest
9
+ readonly isFocus: boolean
10
+ }
11
+
12
+ function extractRepoFromUrl(url: string): string | null {
13
+ const match = url.match(/github\.com\/([^/]+\/[^/]+)\/pull/)
14
+ return match?.[1] ?? null
15
+ }
16
+
17
+ export function PRListItem({
18
+ item,
19
+ isFocus,
20
+ }: PRListItemProps): React.ReactElement {
21
+ const theme = useTheme()
22
+
23
+ const stateColor = item.draft
24
+ ? theme.colors.muted
25
+ : item.state === 'open'
26
+ ? theme.colors.success
27
+ : theme.colors.error
28
+
29
+ const stateIcon = item.draft ? 'D' : item.state === 'open' ? 'O' : 'C'
30
+ const repoName = extractRepoFromUrl(item.html_url)
31
+
32
+ return (
33
+ <Box flexDirection="column" paddingX={1}>
34
+ <Box gap={1}>
35
+ <Text color={stateColor} bold>
36
+ {stateIcon}
37
+ </Text>
38
+ <Text
39
+ color={isFocus ? theme.colors.listSelectedFg : theme.colors.text}
40
+ bold={isFocus}
41
+ inverse={isFocus}
42
+ >
43
+ #{item.number}
44
+ </Text>
45
+ <Text
46
+ color={isFocus ? theme.colors.listSelectedFg : theme.colors.text}
47
+ bold={isFocus}
48
+ inverse={isFocus}
49
+ >
50
+ {item.title}
51
+ </Text>
52
+ </Box>
53
+ <Box gap={1} paddingLeft={3}>
54
+ {repoName && (
55
+ <>
56
+ <Text color={theme.colors.secondary}>{repoName}</Text>
57
+ <Text color={theme.colors.muted}>|</Text>
58
+ </>
59
+ )}
60
+ <Text color={theme.colors.muted}>{item.user.login}</Text>
61
+ <Text color={theme.colors.muted}>|</Text>
62
+ <Text color={theme.colors.muted}>{timeAgo(item.created_at)}</Text>
63
+ {item.requested_reviewers.length > 0 && (
64
+ <>
65
+ <Text color={theme.colors.muted}>|</Text>
66
+ <Text color={theme.colors.info}>
67
+ {item.requested_reviewers.map((r) => r.login).join(', ')}
68
+ </Text>
69
+ </>
70
+ )}
71
+ {item.comments > 0 && (
72
+ <>
73
+ <Text color={theme.colors.muted}>|</Text>
74
+ <Text color={theme.colors.muted}>{item.comments} comments</Text>
75
+ </>
76
+ )}
77
+ {item.labels.length > 0 && (
78
+ <>
79
+ <Text color={theme.colors.muted}>|</Text>
80
+ {item.labels.map(
81
+ (label: { id: number; name: string; color: string }) => (
82
+ <Text key={label.id} color={`#${label.color}`}>
83
+ [{label.name}]
84
+ </Text>
85
+ ),
86
+ )}
87
+ </>
88
+ )}
89
+ </Box>
90
+ </Box>
91
+ )
92
+ }