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,12 @@
1
+ export type { AppError } from './errors'
2
+ export { GitHubError, AuthError, ConfigError, NetworkError } from './errors'
3
+
4
+ export { User } from './user'
5
+ export { PullRequest, Label, BranchRef } from './pull-request'
6
+ export { Comment } from './comment'
7
+ export { Review } from './review'
8
+ export { FileChange } from './file-change'
9
+ export { Commit, CommitDetails, CommitAuthor } from './commit'
10
+
11
+ export type { Diff, FileDiff, Hunk, DiffLine } from './diff'
12
+ export { parseDiffPatch } from './diff'
@@ -0,0 +1,40 @@
1
+ import { Schema as S } from 'effect'
2
+ import { User } from './user'
3
+
4
+ export class Label extends S.Class<Label>('Label')({
5
+ id: S.Number,
6
+ name: S.String,
7
+ color: S.String,
8
+ description: S.optionalWith(S.NullOr(S.String), { default: () => null }),
9
+ }) {}
10
+
11
+ export class BranchRef extends S.Class<BranchRef>('BranchRef')({
12
+ ref: S.optionalWith(S.String, { default: () => '' }),
13
+ sha: S.optionalWith(S.String, { default: () => '' }),
14
+ label: S.optional(S.String),
15
+ }) {}
16
+
17
+ export class PullRequest extends S.Class<PullRequest>('PullRequest')({
18
+ id: S.Number,
19
+ number: S.Number,
20
+ title: S.String,
21
+ body: S.optionalWith(S.NullOr(S.String), { default: () => null }),
22
+ state: S.Literal('open', 'closed'),
23
+ draft: S.optionalWith(S.Boolean, { default: () => false }),
24
+ merged: S.optionalWith(S.Boolean, { default: () => false }),
25
+ user: User,
26
+ labels: S.optionalWith(S.Array(Label), { default: () => [] }),
27
+ created_at: S.String,
28
+ updated_at: S.String,
29
+ merged_at: S.optionalWith(S.NullOr(S.String), { default: () => null }),
30
+ closed_at: S.optionalWith(S.NullOr(S.String), { default: () => null }),
31
+ html_url: S.String,
32
+ head: S.optionalWith(BranchRef, { default: () => new BranchRef({ ref: '', sha: '' }) }),
33
+ base: S.optionalWith(BranchRef, { default: () => new BranchRef({ ref: '', sha: '' }) }),
34
+ additions: S.optionalWith(S.Number, { default: () => 0 }),
35
+ deletions: S.optionalWith(S.Number, { default: () => 0 }),
36
+ changed_files: S.optionalWith(S.Number, { default: () => 0 }),
37
+ comments: S.optionalWith(S.Number, { default: () => 0 }),
38
+ review_comments: S.optionalWith(S.Number, { default: () => 0 }),
39
+ requested_reviewers: S.optionalWith(S.Array(User), { default: () => [] }),
40
+ }) {}
@@ -0,0 +1,17 @@
1
+ import { Schema as S } from 'effect'
2
+ import { User } from './user'
3
+
4
+ export class Review extends S.Class<Review>('Review')({
5
+ id: S.Number,
6
+ user: User,
7
+ body: S.optionalWith(S.NullOr(S.String), { default: () => null }),
8
+ state: S.Literal(
9
+ 'APPROVED',
10
+ 'CHANGES_REQUESTED',
11
+ 'COMMENTED',
12
+ 'DISMISSED',
13
+ 'PENDING',
14
+ ),
15
+ submitted_at: S.optionalWith(S.NullOr(S.String), { default: () => null }),
16
+ html_url: S.String,
17
+ }) {}
@@ -0,0 +1,9 @@
1
+ import { Schema as S } from 'effect'
2
+
3
+ export class User extends S.Class<User>('User')({
4
+ login: S.String,
5
+ id: S.Number,
6
+ avatar_url: S.String,
7
+ html_url: S.String,
8
+ type: S.optionalWith(S.String, { default: () => 'User' }),
9
+ }) {}
@@ -0,0 +1,161 @@
1
+ import React, { useState } from 'react'
2
+ import { Box, Text, useInput } from 'ink'
3
+ import { useTheme } from '../theme/index'
4
+ import { useInvolvedPRs } from '../hooks/useGitHub'
5
+ import { useListNavigation } from '../hooks/useListNavigation'
6
+ import { usePagination } from '../hooks/usePagination'
7
+ import { useFilter } from '../hooks/useFilter'
8
+ import { PRListItem } from '../components/pr/PRListItem'
9
+ import { EmptyState } from '../components/common/EmptyState'
10
+ import { LoadingIndicator } from '../components/common/LoadingIndicator'
11
+ import { PaginationBar } from '../components/common/PaginationBar'
12
+ import { FilterModal } from '../components/common/FilterModal'
13
+ import { SortModal } from '../components/common/SortModal'
14
+ import type { PullRequest } from '../models/pull-request'
15
+
16
+ interface InvolvedScreenProps {
17
+ readonly onSelect: (pr: PullRequest) => void
18
+ }
19
+
20
+ export function InvolvedScreen({
21
+ onSelect,
22
+ }: InvolvedScreenProps): React.ReactElement {
23
+ const theme = useTheme()
24
+ const { data: prs = [], isLoading, error } = useInvolvedPRs()
25
+ const [showFilter, setShowFilter] = useState(false)
26
+ const [showSort, setShowSort] = useState(false)
27
+
28
+ const {
29
+ filter,
30
+ filteredItems,
31
+ setSearch,
32
+ setRepo,
33
+ setAuthor,
34
+ setLabel,
35
+ setSortBy,
36
+ toggleSortDirection,
37
+ clearFilters,
38
+ hasActiveFilters,
39
+ availableRepos,
40
+ availableAuthors,
41
+ availableLabels,
42
+ } = useFilter(prs)
43
+
44
+ const {
45
+ currentPage,
46
+ totalPages,
47
+ pageItems,
48
+ hasNextPage,
49
+ hasPrevPage,
50
+ nextPage,
51
+ prevPage,
52
+ startIndex,
53
+ endIndex,
54
+ } = usePagination(filteredItems, { pageSize: 18 })
55
+
56
+ const { selectedIndex } = useListNavigation({
57
+ itemCount: pageItems.length,
58
+ viewportHeight: pageItems.length,
59
+ isActive: !showFilter && !showSort,
60
+ })
61
+
62
+ useInput(
63
+ (input, key) => {
64
+ if (key.return && pageItems[selectedIndex]) {
65
+ onSelect(pageItems[selectedIndex])
66
+ } else if (input === 'n' && hasNextPage) {
67
+ nextPage()
68
+ } else if (input === 'p' && hasPrevPage) {
69
+ prevPage()
70
+ } else if (input === '/') {
71
+ setShowFilter(true)
72
+ } else if (input === 's') {
73
+ setShowSort(true)
74
+ }
75
+ },
76
+ { isActive: !showFilter && !showSort },
77
+ )
78
+
79
+ if (isLoading && prs.length === 0) {
80
+ return <LoadingIndicator message="Loading involved PRs..." />
81
+ }
82
+
83
+ if (error) {
84
+ return (
85
+ <Box flexDirection="column" padding={1}>
86
+ <Text color={theme.colors.error}>Error: {String(error)}</Text>
87
+ </Box>
88
+ )
89
+ }
90
+
91
+ if (prs.length === 0) {
92
+ return <EmptyState message="No pull requests you're involved in" />
93
+ }
94
+
95
+ return (
96
+ <Box flexDirection="column" flexGrow={1}>
97
+ <Box paddingX={1} justifyContent="space-between">
98
+ <Box gap={2}>
99
+ <Text color={theme.colors.accent} bold>
100
+ Involved Pull Requests
101
+ </Text>
102
+ {hasActiveFilters && (
103
+ <Text color={theme.colors.warning}>(filtered)</Text>
104
+ )}
105
+ <Text color={theme.colors.muted}>/ filter s sort</Text>
106
+ </Box>
107
+ <PaginationBar
108
+ currentPage={currentPage}
109
+ totalPages={totalPages}
110
+ totalItems={filteredItems.length}
111
+ startIndex={startIndex}
112
+ endIndex={endIndex}
113
+ hasNextPage={hasNextPage}
114
+ hasPrevPage={hasPrevPage}
115
+ />
116
+ </Box>
117
+ <Box flexDirection="column">
118
+ {pageItems.length === 0 ? (
119
+ <Box padding={1}>
120
+ <Text color={theme.colors.muted}>
121
+ No PRs match the current filters
122
+ </Text>
123
+ </Box>
124
+ ) : (
125
+ pageItems.map((pr, index) => (
126
+ <PRListItem
127
+ key={pr.id}
128
+ item={pr}
129
+ isFocus={index === selectedIndex}
130
+ />
131
+ ))
132
+ )}
133
+ </Box>
134
+ {showFilter && (
135
+ <FilterModal
136
+ filter={filter}
137
+ availableRepos={availableRepos}
138
+ availableAuthors={availableAuthors}
139
+ availableLabels={availableLabels}
140
+ onSearchChange={setSearch}
141
+ onRepoChange={setRepo}
142
+ onAuthorChange={setAuthor}
143
+ onLabelChange={setLabel}
144
+ onSortChange={setSortBy}
145
+ onSortDirectionToggle={toggleSortDirection}
146
+ onClear={clearFilters}
147
+ onClose={() => setShowFilter(false)}
148
+ />
149
+ )}
150
+ {showSort && (
151
+ <SortModal
152
+ currentSort={filter.sortBy}
153
+ sortDirection={filter.sortDirection}
154
+ onSortChange={setSortBy}
155
+ onSortDirectionToggle={toggleSortDirection}
156
+ onClose={() => setShowSort(false)}
157
+ />
158
+ )}
159
+ </Box>
160
+ )
161
+ }
@@ -0,0 +1,161 @@
1
+ import React, { useState } from 'react'
2
+ import { Box, Text, useInput } from 'ink'
3
+ import { useTheme } from '../theme/index'
4
+ import { useMyPRs } from '../hooks/useGitHub'
5
+ import { useListNavigation } from '../hooks/useListNavigation'
6
+ import { usePagination } from '../hooks/usePagination'
7
+ import { useFilter } from '../hooks/useFilter'
8
+ import { PRListItem } from '../components/pr/PRListItem'
9
+ import { EmptyState } from '../components/common/EmptyState'
10
+ import { LoadingIndicator } from '../components/common/LoadingIndicator'
11
+ import { PaginationBar } from '../components/common/PaginationBar'
12
+ import { FilterModal } from '../components/common/FilterModal'
13
+ import { SortModal } from '../components/common/SortModal'
14
+ import type { PullRequest } from '../models/pull-request'
15
+
16
+ interface MyPRsScreenProps {
17
+ readonly onSelect: (pr: PullRequest) => void
18
+ }
19
+
20
+ export function MyPRsScreen({
21
+ onSelect,
22
+ }: MyPRsScreenProps): React.ReactElement {
23
+ const theme = useTheme()
24
+ const { data: prs = [], isLoading, error } = useMyPRs()
25
+ const [showFilter, setShowFilter] = useState(false)
26
+ const [showSort, setShowSort] = useState(false)
27
+
28
+ const {
29
+ filter,
30
+ filteredItems,
31
+ setSearch,
32
+ setRepo,
33
+ setAuthor,
34
+ setLabel,
35
+ setSortBy,
36
+ toggleSortDirection,
37
+ clearFilters,
38
+ hasActiveFilters,
39
+ availableRepos,
40
+ availableAuthors,
41
+ availableLabels,
42
+ } = useFilter(prs)
43
+
44
+ const {
45
+ currentPage,
46
+ totalPages,
47
+ pageItems,
48
+ hasNextPage,
49
+ hasPrevPage,
50
+ nextPage,
51
+ prevPage,
52
+ startIndex,
53
+ endIndex,
54
+ } = usePagination(filteredItems, { pageSize: 18 })
55
+
56
+ const { selectedIndex } = useListNavigation({
57
+ itemCount: pageItems.length,
58
+ viewportHeight: pageItems.length,
59
+ isActive: !showFilter && !showSort,
60
+ })
61
+
62
+ useInput(
63
+ (input, key) => {
64
+ if (key.return && pageItems[selectedIndex]) {
65
+ onSelect(pageItems[selectedIndex])
66
+ } else if (input === 'n' && hasNextPage) {
67
+ nextPage()
68
+ } else if (input === 'p' && hasPrevPage) {
69
+ prevPage()
70
+ } else if (input === '/') {
71
+ setShowFilter(true)
72
+ } else if (input === 's') {
73
+ setShowSort(true)
74
+ }
75
+ },
76
+ { isActive: !showFilter && !showSort },
77
+ )
78
+
79
+ if (isLoading && prs.length === 0) {
80
+ return <LoadingIndicator message="Loading your PRs..." />
81
+ }
82
+
83
+ if (error) {
84
+ return (
85
+ <Box flexDirection="column" padding={1}>
86
+ <Text color={theme.colors.error}>Error: {String(error)}</Text>
87
+ </Box>
88
+ )
89
+ }
90
+
91
+ if (prs.length === 0) {
92
+ return <EmptyState message="You have no open pull requests" />
93
+ }
94
+
95
+ return (
96
+ <Box flexDirection="column" flexGrow={1}>
97
+ <Box paddingX={1} justifyContent="space-between">
98
+ <Box gap={2}>
99
+ <Text color={theme.colors.accent} bold>
100
+ My Pull Requests
101
+ </Text>
102
+ {hasActiveFilters && (
103
+ <Text color={theme.colors.warning}>(filtered)</Text>
104
+ )}
105
+ <Text color={theme.colors.muted}>/ filter s sort</Text>
106
+ </Box>
107
+ <PaginationBar
108
+ currentPage={currentPage}
109
+ totalPages={totalPages}
110
+ totalItems={filteredItems.length}
111
+ startIndex={startIndex}
112
+ endIndex={endIndex}
113
+ hasNextPage={hasNextPage}
114
+ hasPrevPage={hasPrevPage}
115
+ />
116
+ </Box>
117
+ <Box flexDirection="column">
118
+ {pageItems.length === 0 ? (
119
+ <Box padding={1}>
120
+ <Text color={theme.colors.muted}>
121
+ No PRs match the current filters
122
+ </Text>
123
+ </Box>
124
+ ) : (
125
+ pageItems.map((pr, index) => (
126
+ <PRListItem
127
+ key={pr.id}
128
+ item={pr}
129
+ isFocus={index === selectedIndex}
130
+ />
131
+ ))
132
+ )}
133
+ </Box>
134
+ {showFilter && (
135
+ <FilterModal
136
+ filter={filter}
137
+ availableRepos={availableRepos}
138
+ availableAuthors={availableAuthors}
139
+ availableLabels={availableLabels}
140
+ onSearchChange={setSearch}
141
+ onRepoChange={setRepo}
142
+ onAuthorChange={setAuthor}
143
+ onLabelChange={setLabel}
144
+ onSortChange={setSortBy}
145
+ onSortDirectionToggle={toggleSortDirection}
146
+ onClear={clearFilters}
147
+ onClose={() => setShowFilter(false)}
148
+ />
149
+ )}
150
+ {showSort && (
151
+ <SortModal
152
+ currentSort={filter.sortBy}
153
+ sortDirection={filter.sortDirection}
154
+ onSortChange={setSortBy}
155
+ onSortDirectionToggle={toggleSortDirection}
156
+ onClose={() => setShowSort(false)}
157
+ />
158
+ )}
159
+ </Box>
160
+ )
161
+ }
@@ -0,0 +1,88 @@
1
+ import React, { useState } from 'react'
2
+ import { Box, useInput, useStdout } from 'ink'
3
+ import { Match } from 'effect'
4
+ import { usePRFiles, usePRComments, usePRReviews, usePRCommits } from '../hooks/useGitHub'
5
+ import { PRHeader } from '../components/pr/PRHeader'
6
+ import { PRTabs } from '../components/pr/PRTabs'
7
+ import { FilesTab } from '../components/pr/FilesTab'
8
+ import { ConversationsTab } from '../components/pr/ConversationsTab'
9
+ import { CommitsTab } from '../components/pr/CommitsTab'
10
+ import { LoadingIndicator } from '../components/common/LoadingIndicator'
11
+ import type { PullRequest } from '../models/pull-request'
12
+
13
+ interface PRDetailScreenProps {
14
+ readonly pr: PullRequest
15
+ readonly owner: string
16
+ readonly repo: string
17
+ readonly onBack: () => void
18
+ }
19
+
20
+ const PR_DETAIL_RESERVED_LINES = 12
21
+
22
+ export function PRDetailScreen({
23
+ pr,
24
+ owner,
25
+ repo,
26
+ onBack,
27
+ }: PRDetailScreenProps): React.ReactElement {
28
+ const { stdout } = useStdout()
29
+ const [currentTab, setCurrentTab] = useState(0)
30
+ const contentHeight = Math.max(1, (stdout?.rows ?? 24) - PR_DETAIL_RESERVED_LINES)
31
+
32
+ // Fetch all PR data
33
+ const { data: files = [], isLoading: filesLoading } = usePRFiles(owner, repo, pr.number)
34
+ const { data: comments = [], isLoading: commentsLoading } = usePRComments(owner, repo, pr.number)
35
+ const { data: reviews = [], isLoading: reviewsLoading } = usePRReviews(owner, repo, pr.number)
36
+ const { data: commits = [], isLoading: commitsLoading } = usePRCommits(owner, repo, pr.number)
37
+
38
+ const isLoading = filesLoading || commentsLoading || reviewsLoading || commitsLoading
39
+
40
+ useInput((input, key) => {
41
+ if (input === '1') {
42
+ setCurrentTab(0)
43
+ } else if (input === '2') {
44
+ setCurrentTab(1)
45
+ } else if (input === '3') {
46
+ setCurrentTab(2)
47
+ } else if (input === 'q' || key.escape) {
48
+ onBack()
49
+ }
50
+ })
51
+
52
+ const renderTabContent = (): React.ReactElement => {
53
+ if (isLoading) {
54
+ return <LoadingIndicator message="Loading PR details..." />
55
+ }
56
+
57
+ return Match.value(currentTab).pipe(
58
+ Match.when(0, () => (
59
+ <ConversationsTab
60
+ pr={pr}
61
+ comments={comments}
62
+ reviews={reviews}
63
+ isActive={true}
64
+ />
65
+ )),
66
+ Match.when(1, () => <CommitsTab commits={commits} isActive={true} />),
67
+ Match.when(2, () => <FilesTab files={files} isActive={true} />),
68
+ Match.orElse(() => (
69
+ <ConversationsTab
70
+ pr={pr}
71
+ comments={comments}
72
+ reviews={reviews}
73
+ isActive={true}
74
+ />
75
+ ))
76
+ )
77
+ }
78
+
79
+ return (
80
+ <Box flexDirection="column" flexGrow={1}>
81
+ <PRHeader pr={pr} />
82
+ <PRTabs activeIndex={currentTab} onChange={setCurrentTab} />
83
+ <Box height={contentHeight} overflow="hidden" flexDirection="column">
84
+ {renderTabContent()}
85
+ </Box>
86
+ </Box>
87
+ )
88
+ }
@@ -0,0 +1,96 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+ import { Box, Text, useInput, useStdout } from 'ink'
3
+ import { ScrollList, type ScrollListRef } from 'ink-scroll-list'
4
+ import { useTheme } from '../theme/index'
5
+ import { useGitHub } from '../hooks/useGitHub'
6
+ import { useListNavigation } from '../hooks/useListNavigation'
7
+ import { PRListItem } from '../components/pr/PRListItem'
8
+ import { EmptyState } from '../components/common/EmptyState'
9
+ import { LoadingIndicator } from '../components/common/LoadingIndicator'
10
+ import type { PullRequest } from '../models/pull-request'
11
+
12
+ interface PRListScreenProps {
13
+ readonly owner: string
14
+ readonly repo: string
15
+ readonly onSelect: (pr: PullRequest) => void
16
+ }
17
+
18
+ export function PRListScreen({
19
+ owner,
20
+ repo,
21
+ onSelect,
22
+ }: PRListScreenProps): React.ReactElement {
23
+ const theme = useTheme()
24
+ const { stdout } = useStdout()
25
+ const { prs, loading, error, fetchPRs } = useGitHub()
26
+
27
+ useEffect(() => {
28
+ fetchPRs(owner, repo)
29
+ }, [owner, repo, fetchPRs])
30
+
31
+ const listRef = useRef<ScrollListRef>(null)
32
+ const viewportHeight = Math.max(1, (stdout?.rows ?? 24) - 6)
33
+ const { selectedIndex } = useListNavigation({
34
+ itemCount: prs.length,
35
+ viewportHeight,
36
+ isActive: true,
37
+ })
38
+
39
+ useEffect(() => {
40
+ const handleResize = (): void => listRef.current?.remeasure()
41
+ stdout?.on('resize', handleResize)
42
+ return () => {
43
+ stdout?.off('resize', handleResize)
44
+ }
45
+ }, [stdout])
46
+
47
+ useInput((input, key) => {
48
+ if (key.return && prs[selectedIndex]) {
49
+ onSelect(prs[selectedIndex])
50
+ }
51
+ })
52
+
53
+ if (loading && prs.length === 0) {
54
+ return <LoadingIndicator message="Loading pull requests..." />
55
+ }
56
+
57
+ if (error) {
58
+ return (
59
+ <Box flexDirection="column" padding={1}>
60
+ <Text color={theme.colors.error}>Error: {error}</Text>
61
+ </Box>
62
+ )
63
+ }
64
+
65
+ if (prs.length === 0) {
66
+ return (
67
+ <EmptyState message="No pull requests found" hint={`${owner}/${repo}`} />
68
+ )
69
+ }
70
+
71
+ return (
72
+ <Box flexDirection="column" flexGrow={1}>
73
+ <Box paddingX={1}>
74
+ <Text color={theme.colors.accent} bold>
75
+ Pull Requests
76
+ </Text>
77
+ <Text color={theme.colors.muted}> ({prs.length})</Text>
78
+ <Text color={theme.colors.muted}>
79
+ {' '}
80
+ - {owner}/{repo}
81
+ </Text>
82
+ </Box>
83
+ <Box flexDirection="column" overflow="hidden" height={viewportHeight}>
84
+ <ScrollList ref={listRef} selectedIndex={selectedIndex} scrollAlignment="auto">
85
+ {prs.map((pr, index) => (
86
+ <PRListItem
87
+ key={pr.id}
88
+ item={pr}
89
+ isFocus={index === selectedIndex}
90
+ />
91
+ ))}
92
+ </ScrollList>
93
+ </Box>
94
+ </Box>
95
+ )
96
+ }