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,260 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { Effect } from 'effect'
|
|
3
|
+
import { GitHubApi, type ListPRsOptions } from '../services/GitHubApi'
|
|
4
|
+
import { AppLayer } from '../services/index'
|
|
5
|
+
import type { PullRequest } from '../models/pull-request'
|
|
6
|
+
import type { FileChange } from '../models/file-change'
|
|
7
|
+
import type { Comment } from '../models/comment'
|
|
8
|
+
import type { Review } from '../models/review'
|
|
9
|
+
import type { Commit } from '../models/commit'
|
|
10
|
+
|
|
11
|
+
function runEffect<A>(
|
|
12
|
+
effect: Effect.Effect<A, unknown, unknown>,
|
|
13
|
+
): Promise<A> {
|
|
14
|
+
return Effect.runPromise(
|
|
15
|
+
effect.pipe(Effect.provide(AppLayer)) as Effect.Effect<A, never, never>,
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function usePullRequests(
|
|
20
|
+
owner: string,
|
|
21
|
+
repo: string,
|
|
22
|
+
options?: ListPRsOptions,
|
|
23
|
+
) {
|
|
24
|
+
return useQuery({
|
|
25
|
+
queryKey: ['prs', owner, repo, options],
|
|
26
|
+
queryFn: () =>
|
|
27
|
+
runEffect(
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const api = yield* GitHubApi
|
|
30
|
+
return yield* api.listPullRequests(owner, repo, options)
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
enabled: !!owner && !!repo,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function usePullRequest(owner: string, repo: string, number: number) {
|
|
38
|
+
return useQuery({
|
|
39
|
+
queryKey: ['pr', owner, repo, number],
|
|
40
|
+
queryFn: () =>
|
|
41
|
+
runEffect(
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
const api = yield* GitHubApi
|
|
44
|
+
return yield* api.getPullRequest(owner, repo, number)
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
enabled: !!owner && !!repo && !!number,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function usePRFiles(owner: string, repo: string, number: number) {
|
|
52
|
+
return useQuery({
|
|
53
|
+
queryKey: ['pr-files', owner, repo, number],
|
|
54
|
+
queryFn: () =>
|
|
55
|
+
runEffect(
|
|
56
|
+
Effect.gen(function* () {
|
|
57
|
+
const api = yield* GitHubApi
|
|
58
|
+
return yield* api.getPullRequestFiles(owner, repo, number)
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
enabled: !!owner && !!repo && !!number,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function usePRComments(owner: string, repo: string, number: number) {
|
|
66
|
+
return useQuery({
|
|
67
|
+
queryKey: ['pr-comments', owner, repo, number],
|
|
68
|
+
queryFn: () =>
|
|
69
|
+
runEffect(
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
const api = yield* GitHubApi
|
|
72
|
+
return yield* api.getPullRequestComments(owner, repo, number)
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
75
|
+
enabled: !!owner && !!repo && !!number,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function usePRReviews(owner: string, repo: string, number: number) {
|
|
80
|
+
return useQuery({
|
|
81
|
+
queryKey: ['pr-reviews', owner, repo, number],
|
|
82
|
+
queryFn: () =>
|
|
83
|
+
runEffect(
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
const api = yield* GitHubApi
|
|
86
|
+
return yield* api.getPullRequestReviews(owner, repo, number)
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
enabled: !!owner && !!repo && !!number,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function usePRCommits(owner: string, repo: string, number: number) {
|
|
94
|
+
return useQuery({
|
|
95
|
+
queryKey: ['pr-commits', owner, repo, number],
|
|
96
|
+
queryFn: () =>
|
|
97
|
+
runEffect(
|
|
98
|
+
Effect.gen(function* () {
|
|
99
|
+
const api = yield* GitHubApi
|
|
100
|
+
return yield* api.getPullRequestCommits(owner, repo, number)
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
enabled: !!owner && !!repo && !!number,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function useMyPRs() {
|
|
108
|
+
return useQuery({
|
|
109
|
+
queryKey: ['my-prs'],
|
|
110
|
+
queryFn: () =>
|
|
111
|
+
runEffect(
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const api = yield* GitHubApi
|
|
114
|
+
return yield* api.getMyPRs()
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function useReviewRequests() {
|
|
121
|
+
return useQuery({
|
|
122
|
+
queryKey: ['review-requests'],
|
|
123
|
+
queryFn: () =>
|
|
124
|
+
runEffect(
|
|
125
|
+
Effect.gen(function* () {
|
|
126
|
+
const api = yield* GitHubApi
|
|
127
|
+
return yield* api.getReviewRequests()
|
|
128
|
+
}),
|
|
129
|
+
),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function useInvolvedPRs() {
|
|
134
|
+
return useQuery({
|
|
135
|
+
queryKey: ['involved-prs'],
|
|
136
|
+
queryFn: () =>
|
|
137
|
+
runEffect(
|
|
138
|
+
Effect.gen(function* () {
|
|
139
|
+
const api = yield* GitHubApi
|
|
140
|
+
return yield* api.getInvolvedPRs()
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Legacy hook for backwards compatibility during migration
|
|
147
|
+
interface UseGitHubReturn {
|
|
148
|
+
readonly prs: readonly PullRequest[]
|
|
149
|
+
readonly loading: boolean
|
|
150
|
+
readonly error: string | null
|
|
151
|
+
readonly fetchPRs: (
|
|
152
|
+
owner: string,
|
|
153
|
+
repo: string,
|
|
154
|
+
options?: ListPRsOptions,
|
|
155
|
+
) => void
|
|
156
|
+
readonly fetchPR: (
|
|
157
|
+
owner: string,
|
|
158
|
+
repo: string,
|
|
159
|
+
number: number,
|
|
160
|
+
) => Promise<PullRequest | null>
|
|
161
|
+
readonly fetchFiles: (
|
|
162
|
+
owner: string,
|
|
163
|
+
repo: string,
|
|
164
|
+
number: number,
|
|
165
|
+
) => Promise<readonly FileChange[]>
|
|
166
|
+
readonly fetchComments: (
|
|
167
|
+
owner: string,
|
|
168
|
+
repo: string,
|
|
169
|
+
number: number,
|
|
170
|
+
) => Promise<readonly Comment[]>
|
|
171
|
+
readonly fetchReviews: (
|
|
172
|
+
owner: string,
|
|
173
|
+
repo: string,
|
|
174
|
+
number: number,
|
|
175
|
+
) => Promise<readonly Review[]>
|
|
176
|
+
readonly fetchMyPRs: () => void
|
|
177
|
+
readonly fetchReviewRequests: () => void
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function useGitHub(): UseGitHubReturn {
|
|
181
|
+
const { data: prs = [], isLoading, error } = usePullRequests('', '')
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
prs,
|
|
185
|
+
loading: isLoading,
|
|
186
|
+
error: error ? String(error) : null,
|
|
187
|
+
fetchPRs: (owner, repo, options) => {
|
|
188
|
+
runEffect(
|
|
189
|
+
Effect.gen(function* () {
|
|
190
|
+
const api = yield* GitHubApi
|
|
191
|
+
return yield* api.listPullRequests(owner, repo, options)
|
|
192
|
+
}),
|
|
193
|
+
)
|
|
194
|
+
},
|
|
195
|
+
fetchPR: async (owner, repo, number) => {
|
|
196
|
+
try {
|
|
197
|
+
return await runEffect(
|
|
198
|
+
Effect.gen(function* () {
|
|
199
|
+
const api = yield* GitHubApi
|
|
200
|
+
return yield* api.getPullRequest(owner, repo, number)
|
|
201
|
+
}),
|
|
202
|
+
)
|
|
203
|
+
} catch {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
fetchFiles: async (owner, repo, number) => {
|
|
208
|
+
try {
|
|
209
|
+
return await runEffect(
|
|
210
|
+
Effect.gen(function* () {
|
|
211
|
+
const api = yield* GitHubApi
|
|
212
|
+
return yield* api.getPullRequestFiles(owner, repo, number)
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
} catch {
|
|
216
|
+
return []
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
fetchComments: async (owner, repo, number) => {
|
|
220
|
+
try {
|
|
221
|
+
return await runEffect(
|
|
222
|
+
Effect.gen(function* () {
|
|
223
|
+
const api = yield* GitHubApi
|
|
224
|
+
return yield* api.getPullRequestComments(owner, repo, number)
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
} catch {
|
|
228
|
+
return []
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
fetchReviews: async (owner, repo, number) => {
|
|
232
|
+
try {
|
|
233
|
+
return await runEffect(
|
|
234
|
+
Effect.gen(function* () {
|
|
235
|
+
const api = yield* GitHubApi
|
|
236
|
+
return yield* api.getPullRequestReviews(owner, repo, number)
|
|
237
|
+
}),
|
|
238
|
+
)
|
|
239
|
+
} catch {
|
|
240
|
+
return []
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
fetchMyPRs: () => {
|
|
244
|
+
runEffect(
|
|
245
|
+
Effect.gen(function* () {
|
|
246
|
+
const api = yield* GitHubApi
|
|
247
|
+
return yield* api.getMyPRs()
|
|
248
|
+
}),
|
|
249
|
+
)
|
|
250
|
+
},
|
|
251
|
+
fetchReviewRequests: () => {
|
|
252
|
+
runEffect(
|
|
253
|
+
Effect.gen(function* () {
|
|
254
|
+
const api = yield* GitHubApi
|
|
255
|
+
return yield* api.getReviewRequests()
|
|
256
|
+
}),
|
|
257
|
+
)
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
interface InputFocusContextType {
|
|
4
|
+
readonly isInputActive: boolean
|
|
5
|
+
readonly setInputActive: (active: boolean) => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const InputFocusContext = createContext<InputFocusContextType>({
|
|
9
|
+
isInputActive: false,
|
|
10
|
+
setInputActive: () => {},
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
interface InputFocusProviderProps {
|
|
14
|
+
readonly children: React.ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function InputFocusProvider({
|
|
18
|
+
children,
|
|
19
|
+
}: InputFocusProviderProps): React.ReactElement {
|
|
20
|
+
const [isInputActive, setIsInputActive] = useState(false)
|
|
21
|
+
|
|
22
|
+
const setInputActive = useCallback((active: boolean) => {
|
|
23
|
+
setIsInputActive(active)
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
return React.createElement(
|
|
27
|
+
InputFocusContext.Provider,
|
|
28
|
+
{ value: { isInputActive, setInputActive } },
|
|
29
|
+
children,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useInputFocus(): InputFocusContextType {
|
|
34
|
+
return useContext(InputFocusContext)
|
|
35
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useInput } from 'ink'
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
interface UseListNavigationOptions {
|
|
5
|
+
readonly itemCount: number
|
|
6
|
+
readonly viewportHeight: number
|
|
7
|
+
readonly isActive?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface UseListNavigationResult {
|
|
11
|
+
readonly selectedIndex: number
|
|
12
|
+
readonly scrollOffset: number
|
|
13
|
+
readonly setSelectedIndex: (index: number) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useListNavigation({
|
|
17
|
+
itemCount,
|
|
18
|
+
viewportHeight,
|
|
19
|
+
isActive = true,
|
|
20
|
+
}: UseListNavigationOptions): UseListNavigationResult {
|
|
21
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
22
|
+
const gPressedAt = useRef<number | null>(null)
|
|
23
|
+
const prevItemCount = useRef(itemCount)
|
|
24
|
+
|
|
25
|
+
// Auto-follow: if user was at the last item and a new one arrives, stay at bottom
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const wasAtEnd = selectedIndex === prevItemCount.current - 1
|
|
28
|
+
prevItemCount.current = itemCount
|
|
29
|
+
if (wasAtEnd && itemCount > 0) {
|
|
30
|
+
setSelectedIndex(itemCount - 1)
|
|
31
|
+
}
|
|
32
|
+
}, [itemCount, selectedIndex])
|
|
33
|
+
|
|
34
|
+
// Clamp selectedIndex if itemCount shrinks
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (itemCount === 0) {
|
|
37
|
+
setSelectedIndex(0)
|
|
38
|
+
} else if (selectedIndex >= itemCount) {
|
|
39
|
+
setSelectedIndex(itemCount - 1)
|
|
40
|
+
}
|
|
41
|
+
}, [itemCount, selectedIndex])
|
|
42
|
+
|
|
43
|
+
const clamp = useCallback(
|
|
44
|
+
(index: number) => Math.max(0, Math.min(index, itemCount - 1)),
|
|
45
|
+
[itemCount],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
useInput(
|
|
49
|
+
(input, key) => {
|
|
50
|
+
if (!isActive || itemCount === 0) return
|
|
51
|
+
|
|
52
|
+
// j / ↓ — move down
|
|
53
|
+
if (input === 'j' || key.downArrow) {
|
|
54
|
+
setSelectedIndex((i) => clamp(i + 1))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// k / ↑ — move up
|
|
59
|
+
if (input === 'k' || key.upArrow) {
|
|
60
|
+
setSelectedIndex((i) => clamp(i - 1))
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// G — jump to bottom
|
|
65
|
+
if (input === 'G') {
|
|
66
|
+
setSelectedIndex(itemCount - 1)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// g — first press starts gg detection
|
|
71
|
+
if (input === 'g') {
|
|
72
|
+
const now = Date.now()
|
|
73
|
+
if (gPressedAt.current !== null && now - gPressedAt.current < 500) {
|
|
74
|
+
setSelectedIndex(0)
|
|
75
|
+
gPressedAt.current = null
|
|
76
|
+
} else {
|
|
77
|
+
gPressedAt.current = now
|
|
78
|
+
}
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Ctrl+d — page down
|
|
83
|
+
if (key.ctrl && input === 'd') {
|
|
84
|
+
setSelectedIndex((i) => clamp(i + Math.floor(viewportHeight / 2)))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Ctrl+u — page up
|
|
89
|
+
if (key.ctrl && input === 'u') {
|
|
90
|
+
setSelectedIndex((i) => clamp(i - Math.floor(viewportHeight / 2)))
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Any other key resets gg state
|
|
95
|
+
gPressedAt.current = null
|
|
96
|
+
},
|
|
97
|
+
{ isActive },
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// Derive scroll offset to keep selectedIndex visible
|
|
101
|
+
const scrollOffset = deriveScrollOffset(
|
|
102
|
+
selectedIndex,
|
|
103
|
+
viewportHeight,
|
|
104
|
+
itemCount,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return { selectedIndex, scrollOffset, setSelectedIndex }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function deriveScrollOffset(
|
|
111
|
+
selectedIndex: number,
|
|
112
|
+
viewportHeight: number,
|
|
113
|
+
itemCount: number,
|
|
114
|
+
): number {
|
|
115
|
+
if (itemCount <= viewportHeight) return 0
|
|
116
|
+
|
|
117
|
+
let offset = selectedIndex - Math.floor(viewportHeight / 2)
|
|
118
|
+
offset = Math.max(0, offset)
|
|
119
|
+
offset = Math.min(offset, itemCount - viewportHeight)
|
|
120
|
+
return offset
|
|
121
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import type { LoadingState, LoadingService } from '../services/Loading'
|
|
3
|
+
|
|
4
|
+
let loadingService: LoadingService | null = null
|
|
5
|
+
|
|
6
|
+
export function setLoadingService(service: LoadingService): void {
|
|
7
|
+
loadingService = service
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getLoadingService(): LoadingService | null {
|
|
11
|
+
return loadingService
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const emptyState: LoadingState = { isLoading: false, message: null }
|
|
15
|
+
|
|
16
|
+
export function useLoading(): LoadingState {
|
|
17
|
+
return useSyncExternalStore(
|
|
18
|
+
(callback) => {
|
|
19
|
+
if (!loadingService) return () => {}
|
|
20
|
+
return loadingService.subscribe(callback)
|
|
21
|
+
},
|
|
22
|
+
() => loadingService?.getState() ?? emptyState,
|
|
23
|
+
() => emptyState,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
interface UsePaginationOptions {
|
|
4
|
+
readonly pageSize?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface UsePaginationResult<T> {
|
|
8
|
+
readonly currentPage: number
|
|
9
|
+
readonly totalPages: number
|
|
10
|
+
readonly pageItems: readonly T[]
|
|
11
|
+
readonly hasNextPage: boolean
|
|
12
|
+
readonly hasPrevPage: boolean
|
|
13
|
+
readonly nextPage: () => void
|
|
14
|
+
readonly prevPage: () => void
|
|
15
|
+
readonly goToPage: (page: number) => void
|
|
16
|
+
readonly startIndex: number
|
|
17
|
+
readonly endIndex: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function usePagination<T>(
|
|
21
|
+
items: readonly T[],
|
|
22
|
+
options: UsePaginationOptions = {},
|
|
23
|
+
): UsePaginationResult<T> {
|
|
24
|
+
const pageSize = options.pageSize ?? 18
|
|
25
|
+
const [currentPage, setCurrentPage] = useState(1)
|
|
26
|
+
const prevItemsLengthRef = useRef(items.length)
|
|
27
|
+
|
|
28
|
+
const totalPages = useMemo(
|
|
29
|
+
() => Math.max(1, Math.ceil(items.length / pageSize)),
|
|
30
|
+
[items.length, pageSize],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// Reset to page 1 when items change (e.g., filter applied)
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (items.length !== prevItemsLengthRef.current) {
|
|
36
|
+
setCurrentPage(1)
|
|
37
|
+
prevItemsLengthRef.current = items.length
|
|
38
|
+
}
|
|
39
|
+
}, [items.length])
|
|
40
|
+
|
|
41
|
+
// Ensure current page is within bounds
|
|
42
|
+
const safePage = Math.min(currentPage, totalPages)
|
|
43
|
+
|
|
44
|
+
const startIndex = (safePage - 1) * pageSize
|
|
45
|
+
const endIndex = Math.min(startIndex + pageSize, items.length)
|
|
46
|
+
|
|
47
|
+
const pageItems = useMemo(
|
|
48
|
+
() => items.slice(startIndex, endIndex),
|
|
49
|
+
[items, startIndex, endIndex],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const hasNextPage = safePage < totalPages
|
|
53
|
+
const hasPrevPage = safePage > 1
|
|
54
|
+
|
|
55
|
+
const nextPage = useCallback(() => {
|
|
56
|
+
if (hasNextPage) {
|
|
57
|
+
setCurrentPage((p) => p + 1)
|
|
58
|
+
}
|
|
59
|
+
}, [hasNextPage])
|
|
60
|
+
|
|
61
|
+
const prevPage = useCallback(() => {
|
|
62
|
+
if (hasPrevPage) {
|
|
63
|
+
setCurrentPage((p) => p - 1)
|
|
64
|
+
}
|
|
65
|
+
}, [hasPrevPage])
|
|
66
|
+
|
|
67
|
+
const goToPage = useCallback(
|
|
68
|
+
(page: number) => {
|
|
69
|
+
const clampedPage = Math.max(1, Math.min(page, totalPages))
|
|
70
|
+
setCurrentPage(clampedPage)
|
|
71
|
+
},
|
|
72
|
+
[totalPages],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
currentPage: safePage,
|
|
77
|
+
totalPages,
|
|
78
|
+
pageItems,
|
|
79
|
+
hasNextPage,
|
|
80
|
+
hasPrevPage,
|
|
81
|
+
nextPage,
|
|
82
|
+
prevPage,
|
|
83
|
+
goToPage,
|
|
84
|
+
startIndex,
|
|
85
|
+
endIndex,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Schema as S } from 'effect'
|
|
2
|
+
import { User } from './user'
|
|
3
|
+
|
|
4
|
+
export class Comment extends S.Class<Comment>('Comment')({
|
|
5
|
+
id: S.Number,
|
|
6
|
+
body: S.String,
|
|
7
|
+
user: User,
|
|
8
|
+
created_at: S.String,
|
|
9
|
+
updated_at: S.String,
|
|
10
|
+
html_url: S.String,
|
|
11
|
+
path: S.optional(S.String),
|
|
12
|
+
line: S.optional(S.NullOr(S.Number)),
|
|
13
|
+
side: S.optional(S.Literal('LEFT', 'RIGHT')),
|
|
14
|
+
in_reply_to_id: S.optional(S.Number),
|
|
15
|
+
}) {}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Schema as S } from 'effect'
|
|
2
|
+
import { User } from './user'
|
|
3
|
+
|
|
4
|
+
export class CommitAuthor extends S.Class<CommitAuthor>('CommitAuthor')({
|
|
5
|
+
name: S.String,
|
|
6
|
+
email: S.String,
|
|
7
|
+
date: S.String,
|
|
8
|
+
}) {}
|
|
9
|
+
|
|
10
|
+
export class CommitDetails extends S.Class<CommitDetails>('CommitDetails')({
|
|
11
|
+
message: S.String,
|
|
12
|
+
author: CommitAuthor,
|
|
13
|
+
}) {}
|
|
14
|
+
|
|
15
|
+
export class Commit extends S.Class<Commit>('Commit')({
|
|
16
|
+
sha: S.String,
|
|
17
|
+
commit: CommitDetails,
|
|
18
|
+
author: S.optionalWith(S.NullOr(User), { default: () => null }),
|
|
19
|
+
html_url: S.String,
|
|
20
|
+
}) {}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface DiffLine {
|
|
2
|
+
readonly type: 'add' | 'del' | 'context' | 'header'
|
|
3
|
+
readonly content: string
|
|
4
|
+
readonly oldLineNumber?: number
|
|
5
|
+
readonly newLineNumber?: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Hunk {
|
|
9
|
+
readonly header: string
|
|
10
|
+
readonly oldStart: number
|
|
11
|
+
readonly oldCount: number
|
|
12
|
+
readonly newStart: number
|
|
13
|
+
readonly newCount: number
|
|
14
|
+
readonly lines: readonly DiffLine[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FileDiff {
|
|
18
|
+
readonly filename: string
|
|
19
|
+
readonly status: 'added' | 'removed' | 'modified' | 'renamed'
|
|
20
|
+
readonly additions: number
|
|
21
|
+
readonly deletions: number
|
|
22
|
+
readonly hunks: readonly Hunk[]
|
|
23
|
+
readonly previousFilename?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Diff {
|
|
27
|
+
readonly files: readonly FileDiff[]
|
|
28
|
+
readonly totalAdditions: number
|
|
29
|
+
readonly totalDeletions: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseDiffPatch(patch: string): readonly Hunk[] {
|
|
33
|
+
const hunks: Hunk[] = []
|
|
34
|
+
const lines = patch.split('\n')
|
|
35
|
+
let currentHunk: Hunk | null = null
|
|
36
|
+
let currentLines: DiffLine[] = []
|
|
37
|
+
let oldLine = 0
|
|
38
|
+
let newLine = 0
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const hunkMatch = line.match(
|
|
42
|
+
/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if (hunkMatch) {
|
|
46
|
+
if (currentHunk) {
|
|
47
|
+
hunks.push({ ...currentHunk, lines: currentLines })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
oldLine = parseInt(hunkMatch[1]!, 10)
|
|
51
|
+
newLine = parseInt(hunkMatch[3]!, 10)
|
|
52
|
+
currentLines = [{ type: 'header', content: line }]
|
|
53
|
+
currentHunk = {
|
|
54
|
+
header: line,
|
|
55
|
+
oldStart: oldLine,
|
|
56
|
+
oldCount: parseInt(hunkMatch[2] ?? '1', 10),
|
|
57
|
+
newStart: newLine,
|
|
58
|
+
newCount: parseInt(hunkMatch[4] ?? '1', 10),
|
|
59
|
+
lines: [],
|
|
60
|
+
}
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!currentHunk) continue
|
|
65
|
+
|
|
66
|
+
if (line.startsWith('+')) {
|
|
67
|
+
currentLines.push({
|
|
68
|
+
type: 'add',
|
|
69
|
+
content: line.slice(1),
|
|
70
|
+
newLineNumber: newLine++,
|
|
71
|
+
})
|
|
72
|
+
} else if (line.startsWith('-')) {
|
|
73
|
+
currentLines.push({
|
|
74
|
+
type: 'del',
|
|
75
|
+
content: line.slice(1),
|
|
76
|
+
oldLineNumber: oldLine++,
|
|
77
|
+
})
|
|
78
|
+
} else if (line.startsWith(' ') || line === '') {
|
|
79
|
+
currentLines.push({
|
|
80
|
+
type: 'context',
|
|
81
|
+
content: line.slice(1),
|
|
82
|
+
oldLineNumber: oldLine++,
|
|
83
|
+
newLineNumber: newLine++,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (currentHunk) {
|
|
89
|
+
hunks.push({ ...currentHunk, lines: currentLines })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return hunks
|
|
93
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Data } from 'effect'
|
|
2
|
+
|
|
3
|
+
export class GitHubError extends Data.TaggedError('GitHubError')<{
|
|
4
|
+
readonly message: string
|
|
5
|
+
readonly status?: number
|
|
6
|
+
readonly url?: string
|
|
7
|
+
}> {}
|
|
8
|
+
|
|
9
|
+
export class AuthError extends Data.TaggedError('AuthError')<{
|
|
10
|
+
readonly message: string
|
|
11
|
+
readonly reason: 'no_token' | 'invalid_token' | 'expired_token' | 'save_failed'
|
|
12
|
+
}> {}
|
|
13
|
+
|
|
14
|
+
export class ConfigError extends Data.TaggedError('ConfigError')<{
|
|
15
|
+
readonly message: string
|
|
16
|
+
readonly path?: string
|
|
17
|
+
}> {}
|
|
18
|
+
|
|
19
|
+
export class NetworkError extends Data.TaggedError('NetworkError')<{
|
|
20
|
+
readonly message: string
|
|
21
|
+
readonly cause?: unknown
|
|
22
|
+
}> {}
|
|
23
|
+
|
|
24
|
+
export type AppError = GitHubError | AuthError | ConfigError | NetworkError
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Schema as S } from 'effect'
|
|
2
|
+
|
|
3
|
+
export class FileChange extends S.Class<FileChange>('FileChange')({
|
|
4
|
+
sha: S.String,
|
|
5
|
+
filename: S.String,
|
|
6
|
+
status: S.Literal(
|
|
7
|
+
'added',
|
|
8
|
+
'removed',
|
|
9
|
+
'modified',
|
|
10
|
+
'renamed',
|
|
11
|
+
'copied',
|
|
12
|
+
'changed',
|
|
13
|
+
'unchanged',
|
|
14
|
+
),
|
|
15
|
+
additions: S.Number,
|
|
16
|
+
deletions: S.Number,
|
|
17
|
+
changes: S.Number,
|
|
18
|
+
patch: S.optional(S.String),
|
|
19
|
+
previous_filename: S.optional(S.String),
|
|
20
|
+
blob_url: S.optional(S.String),
|
|
21
|
+
raw_url: S.optional(S.String),
|
|
22
|
+
}) {}
|