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,50 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box } from 'ink'
|
|
3
|
+
import { Tab, Tabs } from 'ink-tab'
|
|
4
|
+
import { useTheme } from '../../theme/index'
|
|
5
|
+
|
|
6
|
+
export const PR_TAB_NAMES = ['Conversations', 'Commits', 'Files'] as const
|
|
7
|
+
export type PRTabName = (typeof PR_TAB_NAMES)[number]
|
|
8
|
+
|
|
9
|
+
interface PRTabsProps {
|
|
10
|
+
readonly activeIndex: number
|
|
11
|
+
readonly onChange: (index: number) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function PRTabs({ activeIndex, onChange }: PRTabsProps): React.ReactElement {
|
|
15
|
+
const theme = useTheme()
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Box
|
|
19
|
+
flexDirection="row"
|
|
20
|
+
paddingX={1}
|
|
21
|
+
paddingY={1}
|
|
22
|
+
borderStyle="single"
|
|
23
|
+
borderColor={theme.colors.border}
|
|
24
|
+
>
|
|
25
|
+
<Tabs
|
|
26
|
+
key={activeIndex}
|
|
27
|
+
defaultValue={PR_TAB_NAMES[activeIndex]}
|
|
28
|
+
onChange={(name) => {
|
|
29
|
+
const index = PR_TAB_NAMES.indexOf(name as PRTabName)
|
|
30
|
+
if (index >= 0) onChange(index)
|
|
31
|
+
}}
|
|
32
|
+
showIndex={true}
|
|
33
|
+
isFocused={true}
|
|
34
|
+
colors={{
|
|
35
|
+
activeTab: {
|
|
36
|
+
color: theme.colors.accent,
|
|
37
|
+
backgroundColor: theme.colors.bg,
|
|
38
|
+
},
|
|
39
|
+
}}
|
|
40
|
+
keyMap={{ useNumbers: true, useTab: true }}
|
|
41
|
+
>
|
|
42
|
+
{PR_TAB_NAMES.map((name) => (
|
|
43
|
+
<Tab key={name} name={name}>
|
|
44
|
+
{name}
|
|
45
|
+
</Tab>
|
|
46
|
+
))}
|
|
47
|
+
</Tabs>
|
|
48
|
+
</Box>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { PRListItem } from './PRListItem'
|
|
2
|
+
export { PRHeader } from './PRHeader'
|
|
3
|
+
export { PRTabs, PR_TAB_NAMES } from './PRTabs'
|
|
4
|
+
export type { PRTabName } from './PRTabs'
|
|
5
|
+
export { FilesTab } from './FilesTab'
|
|
6
|
+
export { CommentsTab } from './CommentsTab'
|
|
7
|
+
export { ConversationsTab } from './ConversationsTab'
|
|
8
|
+
export { CommitsTab } from './CommitsTab'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { useAuth } from './useAuth'
|
|
2
|
+
export { useConfig } from './useConfig'
|
|
3
|
+
export { useGitHub } from './useGitHub'
|
|
4
|
+
export { useLoading, setLoadingService, getLoadingService } from './useLoading'
|
|
5
|
+
export { useAppKeymap } from './useAppKeymap'
|
|
6
|
+
export { useTheme } from '../theme/index'
|
|
7
|
+
export { useListNavigation } from './useListNavigation'
|
|
8
|
+
export { useActivePanel } from './useActivePanel'
|
|
9
|
+
export { usePagination } from './usePagination'
|
|
10
|
+
export { useFilter } from './useFilter'
|
|
11
|
+
export type { Panel } from './useActivePanel'
|
|
12
|
+
export type { SortField, SortDirection, FilterState } from './useFilter'
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useInput } from 'ink'
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
export type Panel = 'sidebar' | 'list' | 'detail'
|
|
5
|
+
|
|
6
|
+
interface UseActivePanelOptions {
|
|
7
|
+
readonly hasSelection: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface UseActivePanelResult {
|
|
11
|
+
readonly activePanel: Panel
|
|
12
|
+
readonly setActivePanel: (panel: Panel) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useActivePanel({
|
|
16
|
+
hasSelection,
|
|
17
|
+
}: UseActivePanelOptions): UseActivePanelResult {
|
|
18
|
+
const [activePanel, setActivePanel] = useState<Panel>('sidebar')
|
|
19
|
+
|
|
20
|
+
// Reset to list when selection is lost
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!hasSelection && activePanel === 'detail') {
|
|
23
|
+
setActivePanel('list')
|
|
24
|
+
}
|
|
25
|
+
}, [hasSelection, activePanel])
|
|
26
|
+
|
|
27
|
+
const handleEscape = useCallback(() => {
|
|
28
|
+
if (activePanel === 'detail') {
|
|
29
|
+
setActivePanel('list')
|
|
30
|
+
} else if (activePanel === 'list') {
|
|
31
|
+
setActivePanel('sidebar')
|
|
32
|
+
}
|
|
33
|
+
}, [activePanel])
|
|
34
|
+
|
|
35
|
+
const handleTab = useCallback(() => {
|
|
36
|
+
if (activePanel === 'sidebar') {
|
|
37
|
+
setActivePanel('list')
|
|
38
|
+
} else if (activePanel === 'list' && hasSelection) {
|
|
39
|
+
setActivePanel('detail')
|
|
40
|
+
} else if (activePanel === 'detail') {
|
|
41
|
+
setActivePanel('sidebar')
|
|
42
|
+
}
|
|
43
|
+
}, [activePanel, hasSelection])
|
|
44
|
+
|
|
45
|
+
useInput((_input, key) => {
|
|
46
|
+
if (key.escape) {
|
|
47
|
+
handleEscape()
|
|
48
|
+
} else if (key.tab) {
|
|
49
|
+
handleTab()
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
return { activePanel, setActivePanel }
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useInput } from 'ink'
|
|
2
|
+
|
|
3
|
+
interface AppKeymapActions {
|
|
4
|
+
readonly toggleSidebar: () => void
|
|
5
|
+
readonly toggleHelp: () => void
|
|
6
|
+
readonly quit: () => void
|
|
7
|
+
readonly switchFocus: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useAppKeymap(actions: AppKeymapActions): void {
|
|
11
|
+
useInput((input, key) => {
|
|
12
|
+
if (input === 'b') {
|
|
13
|
+
actions.toggleSidebar()
|
|
14
|
+
} else if (input === '?') {
|
|
15
|
+
actions.toggleHelp()
|
|
16
|
+
} else if (input === 'q') {
|
|
17
|
+
actions.quit()
|
|
18
|
+
} else if (key.tab) {
|
|
19
|
+
actions.switchFocus()
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { Effect } from 'effect'
|
|
3
|
+
import { Auth, AuthLive } from '../services/Auth'
|
|
4
|
+
import type { TokenSource, TokenInfo } from '../services/Auth'
|
|
5
|
+
import type { User } from '../models/user'
|
|
6
|
+
|
|
7
|
+
interface UseAuthReturn {
|
|
8
|
+
readonly user: User | null
|
|
9
|
+
readonly isAuthenticated: boolean
|
|
10
|
+
readonly error: string | null
|
|
11
|
+
readonly loading: boolean
|
|
12
|
+
readonly saveToken: (token: string) => Promise<void>
|
|
13
|
+
readonly isSavingToken: boolean
|
|
14
|
+
readonly tokenInfo: TokenInfo | null
|
|
15
|
+
readonly availableSources: TokenSource[]
|
|
16
|
+
readonly setPreferredSource: (source: TokenSource) => Promise<void>
|
|
17
|
+
readonly clearManualToken: () => Promise<void>
|
|
18
|
+
readonly refetch: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useAuth(): UseAuthReturn {
|
|
22
|
+
const queryClient = useQueryClient()
|
|
23
|
+
|
|
24
|
+
const { data, error, isLoading, refetch } = useQuery({
|
|
25
|
+
queryKey: ['auth'],
|
|
26
|
+
queryFn: () =>
|
|
27
|
+
Effect.runPromise(
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
const auth = yield* Auth
|
|
30
|
+
const authenticated = yield* auth.isAuthenticated()
|
|
31
|
+
|
|
32
|
+
if (!authenticated) {
|
|
33
|
+
return { user: null, isAuthenticated: false }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const currentUser = yield* auth.getUser()
|
|
37
|
+
return { user: currentUser, isAuthenticated: true }
|
|
38
|
+
}).pipe(Effect.provide(AuthLive)),
|
|
39
|
+
),
|
|
40
|
+
staleTime: Infinity,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const { data: tokenInfoData } = useQuery({
|
|
44
|
+
queryKey: ['tokenInfo'],
|
|
45
|
+
queryFn: () =>
|
|
46
|
+
Effect.runPromise(
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const auth = yield* Auth
|
|
49
|
+
return yield* auth.getTokenInfo()
|
|
50
|
+
}).pipe(Effect.provide(AuthLive)),
|
|
51
|
+
),
|
|
52
|
+
staleTime: 0, // Always refetch
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const { data: availableSourcesData } = useQuery({
|
|
56
|
+
queryKey: ['availableSources'],
|
|
57
|
+
queryFn: () =>
|
|
58
|
+
Effect.runPromise(
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
const auth = yield* Auth
|
|
61
|
+
return yield* auth.getAvailableSources()
|
|
62
|
+
}).pipe(Effect.provide(AuthLive)),
|
|
63
|
+
),
|
|
64
|
+
staleTime: 0,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const saveTokenMutation = useMutation({
|
|
68
|
+
mutationFn: async (token: string) => {
|
|
69
|
+
await Effect.runPromise(
|
|
70
|
+
Effect.gen(function* () {
|
|
71
|
+
const auth = yield* Auth
|
|
72
|
+
yield* auth.setToken(token)
|
|
73
|
+
}).pipe(Effect.provide(AuthLive)),
|
|
74
|
+
)
|
|
75
|
+
},
|
|
76
|
+
onSuccess: () => {
|
|
77
|
+
queryClient.invalidateQueries({ queryKey: ['auth'] })
|
|
78
|
+
queryClient.invalidateQueries({ queryKey: ['tokenInfo'] })
|
|
79
|
+
queryClient.invalidateQueries({ queryKey: ['availableSources'] })
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const setPreferredSourceMutation = useMutation({
|
|
84
|
+
mutationFn: async (source: TokenSource) => {
|
|
85
|
+
await Effect.runPromise(
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const auth = yield* Auth
|
|
88
|
+
yield* auth.setPreferredSource(source)
|
|
89
|
+
}).pipe(Effect.provide(AuthLive)),
|
|
90
|
+
)
|
|
91
|
+
},
|
|
92
|
+
onSuccess: () => {
|
|
93
|
+
queryClient.invalidateQueries({ queryKey: ['auth'] })
|
|
94
|
+
queryClient.invalidateQueries({ queryKey: ['tokenInfo'] })
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const clearManualTokenMutation = useMutation({
|
|
99
|
+
mutationFn: async () => {
|
|
100
|
+
await Effect.runPromise(
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
const auth = yield* Auth
|
|
103
|
+
yield* auth.clearManualToken()
|
|
104
|
+
}).pipe(Effect.provide(AuthLive)),
|
|
105
|
+
)
|
|
106
|
+
},
|
|
107
|
+
onSuccess: () => {
|
|
108
|
+
queryClient.invalidateQueries({ queryKey: ['auth'] })
|
|
109
|
+
queryClient.invalidateQueries({ queryKey: ['tokenInfo'] })
|
|
110
|
+
queryClient.invalidateQueries({ queryKey: ['availableSources'] })
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
user: data?.user ?? null,
|
|
116
|
+
isAuthenticated: data?.isAuthenticated ?? false,
|
|
117
|
+
error: error ? String(error) : null,
|
|
118
|
+
loading: isLoading,
|
|
119
|
+
saveToken: saveTokenMutation.mutateAsync,
|
|
120
|
+
isSavingToken: saveTokenMutation.isPending,
|
|
121
|
+
tokenInfo: tokenInfoData ?? null,
|
|
122
|
+
availableSources: availableSourcesData ?? [],
|
|
123
|
+
setPreferredSource: setPreferredSourceMutation.mutateAsync,
|
|
124
|
+
clearManualToken: clearManualTokenMutation.mutateAsync,
|
|
125
|
+
refetch: () => {
|
|
126
|
+
refetch()
|
|
127
|
+
queryClient.invalidateQueries({ queryKey: ['tokenInfo'] })
|
|
128
|
+
queryClient.invalidateQueries({ queryKey: ['availableSources'] })
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { Effect } from 'effect'
|
|
3
|
+
import { Config, ConfigLive, type AppConfig } from '../services/Config'
|
|
4
|
+
|
|
5
|
+
interface UseConfigReturn {
|
|
6
|
+
readonly config: AppConfig | null
|
|
7
|
+
readonly error: string | null
|
|
8
|
+
readonly loading: boolean
|
|
9
|
+
readonly updateConfig: (updates: Partial<AppConfig>) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useConfig(): UseConfigReturn {
|
|
13
|
+
const queryClient = useQueryClient()
|
|
14
|
+
|
|
15
|
+
const { data, error, isLoading } = useQuery({
|
|
16
|
+
queryKey: ['config'],
|
|
17
|
+
queryFn: () =>
|
|
18
|
+
Effect.runPromise(
|
|
19
|
+
Effect.gen(function* () {
|
|
20
|
+
const configService = yield* Config
|
|
21
|
+
return yield* configService.load()
|
|
22
|
+
}).pipe(Effect.provide(ConfigLive)),
|
|
23
|
+
),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const mutation = useMutation({
|
|
27
|
+
mutationFn: (newConfig: AppConfig) =>
|
|
28
|
+
Effect.runPromise(
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
const configService = yield* Config
|
|
31
|
+
yield* configService.save(newConfig)
|
|
32
|
+
}).pipe(Effect.provide(ConfigLive)),
|
|
33
|
+
),
|
|
34
|
+
onSuccess: () => {
|
|
35
|
+
queryClient.invalidateQueries({ queryKey: ['config'] })
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const updateConfig = (updates: Partial<AppConfig>) => {
|
|
40
|
+
if (!data) return
|
|
41
|
+
const newConfig = { ...data, ...updates } as AppConfig
|
|
42
|
+
queryClient.setQueryData(['config'], newConfig)
|
|
43
|
+
mutation.mutate(newConfig)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
config: data ?? null,
|
|
48
|
+
error: error ? String(error) : null,
|
|
49
|
+
loading: isLoading,
|
|
50
|
+
updateConfig,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback } from 'react'
|
|
2
|
+
import type { PullRequest } from '../models/pull-request'
|
|
3
|
+
|
|
4
|
+
export type SortField = 'updated' | 'created' | 'repo' | 'author' | 'title'
|
|
5
|
+
export type SortDirection = 'asc' | 'desc'
|
|
6
|
+
|
|
7
|
+
export interface FilterState {
|
|
8
|
+
readonly search: string
|
|
9
|
+
readonly repo: string | null
|
|
10
|
+
readonly author: string | null
|
|
11
|
+
readonly label: string | null
|
|
12
|
+
readonly sortBy: SortField
|
|
13
|
+
readonly sortDirection: SortDirection
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const defaultFilter: FilterState = {
|
|
17
|
+
search: '',
|
|
18
|
+
repo: null,
|
|
19
|
+
author: null,
|
|
20
|
+
label: null,
|
|
21
|
+
sortBy: 'updated',
|
|
22
|
+
sortDirection: 'desc',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function extractRepoFromUrl(url: string): string | null {
|
|
26
|
+
const match = url.match(/github\.com\/([^/]+\/[^/]+)\/pull/)
|
|
27
|
+
return match?.[1] ?? null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function matchesSearch(pr: PullRequest, search: string): boolean {
|
|
31
|
+
if (!search) return true
|
|
32
|
+
const lowerSearch = search.toLowerCase()
|
|
33
|
+
return (
|
|
34
|
+
pr.title.toLowerCase().includes(lowerSearch) ||
|
|
35
|
+
pr.user.login.toLowerCase().includes(lowerSearch) ||
|
|
36
|
+
String(pr.number).includes(lowerSearch)
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchesRepo(pr: PullRequest, repo: string | null): boolean {
|
|
41
|
+
if (!repo) return true
|
|
42
|
+
const prRepo = extractRepoFromUrl(pr.html_url)
|
|
43
|
+
return prRepo?.toLowerCase().includes(repo.toLowerCase()) ?? false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function matchesAuthor(pr: PullRequest, author: string | null): boolean {
|
|
47
|
+
if (!author) return true
|
|
48
|
+
return pr.user.login.toLowerCase().includes(author.toLowerCase())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function matchesLabel(pr: PullRequest, label: string | null): boolean {
|
|
52
|
+
if (!label) return true
|
|
53
|
+
const lowerLabel = label.toLowerCase()
|
|
54
|
+
return pr.labels.some((l) => l.name.toLowerCase().includes(lowerLabel))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function comparePRs(
|
|
58
|
+
a: PullRequest,
|
|
59
|
+
b: PullRequest,
|
|
60
|
+
sortBy: SortField,
|
|
61
|
+
sortDirection: SortDirection,
|
|
62
|
+
): number {
|
|
63
|
+
let comparison = 0
|
|
64
|
+
|
|
65
|
+
switch (sortBy) {
|
|
66
|
+
case 'updated':
|
|
67
|
+
comparison =
|
|
68
|
+
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
|
69
|
+
break
|
|
70
|
+
case 'created':
|
|
71
|
+
comparison =
|
|
72
|
+
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
73
|
+
break
|
|
74
|
+
case 'repo': {
|
|
75
|
+
const repoA = extractRepoFromUrl(a.html_url) ?? ''
|
|
76
|
+
const repoB = extractRepoFromUrl(b.html_url) ?? ''
|
|
77
|
+
comparison = repoA.localeCompare(repoB)
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
case 'author':
|
|
81
|
+
comparison = a.user.login.localeCompare(b.user.login)
|
|
82
|
+
break
|
|
83
|
+
case 'title':
|
|
84
|
+
comparison = a.title.localeCompare(b.title)
|
|
85
|
+
break
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return sortDirection === 'asc' ? -comparison : comparison
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface UseFilterResult {
|
|
92
|
+
readonly filter: FilterState
|
|
93
|
+
readonly filteredItems: readonly PullRequest[]
|
|
94
|
+
readonly setSearch: (search: string) => void
|
|
95
|
+
readonly setRepo: (repo: string | null) => void
|
|
96
|
+
readonly setAuthor: (author: string | null) => void
|
|
97
|
+
readonly setLabel: (label: string | null) => void
|
|
98
|
+
readonly setSortBy: (sortBy: SortField) => void
|
|
99
|
+
readonly toggleSortDirection: () => void
|
|
100
|
+
readonly clearFilters: () => void
|
|
101
|
+
readonly hasActiveFilters: boolean
|
|
102
|
+
readonly availableRepos: readonly string[]
|
|
103
|
+
readonly availableAuthors: readonly string[]
|
|
104
|
+
readonly availableLabels: readonly string[]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function useFilter(items: readonly PullRequest[]): UseFilterResult {
|
|
108
|
+
const [filter, setFilter] = useState<FilterState>(defaultFilter)
|
|
109
|
+
|
|
110
|
+
const availableRepos = useMemo(() => {
|
|
111
|
+
const repos = new Set<string>()
|
|
112
|
+
items.forEach((pr) => {
|
|
113
|
+
const repo = extractRepoFromUrl(pr.html_url)
|
|
114
|
+
if (repo) repos.add(repo)
|
|
115
|
+
})
|
|
116
|
+
return Array.from(repos).sort()
|
|
117
|
+
}, [items])
|
|
118
|
+
|
|
119
|
+
const availableAuthors = useMemo(() => {
|
|
120
|
+
const authors = new Set<string>()
|
|
121
|
+
items.forEach((pr) => authors.add(pr.user.login))
|
|
122
|
+
return Array.from(authors).sort()
|
|
123
|
+
}, [items])
|
|
124
|
+
|
|
125
|
+
const availableLabels = useMemo(() => {
|
|
126
|
+
const labels = new Set<string>()
|
|
127
|
+
items.forEach((pr) => pr.labels.forEach((l) => labels.add(l.name)))
|
|
128
|
+
return Array.from(labels).sort()
|
|
129
|
+
}, [items])
|
|
130
|
+
|
|
131
|
+
const filteredItems = useMemo(() => {
|
|
132
|
+
return items
|
|
133
|
+
.filter((pr) => matchesSearch(pr, filter.search))
|
|
134
|
+
.filter((pr) => matchesRepo(pr, filter.repo))
|
|
135
|
+
.filter((pr) => matchesAuthor(pr, filter.author))
|
|
136
|
+
.filter((pr) => matchesLabel(pr, filter.label))
|
|
137
|
+
.sort((a, b) => comparePRs(a, b, filter.sortBy, filter.sortDirection))
|
|
138
|
+
}, [items, filter])
|
|
139
|
+
|
|
140
|
+
const setSearch = useCallback((search: string) => {
|
|
141
|
+
setFilter((prev) => ({ ...prev, search }))
|
|
142
|
+
}, [])
|
|
143
|
+
|
|
144
|
+
const setRepo = useCallback((repo: string | null) => {
|
|
145
|
+
setFilter((prev) => ({ ...prev, repo }))
|
|
146
|
+
}, [])
|
|
147
|
+
|
|
148
|
+
const setAuthor = useCallback((author: string | null) => {
|
|
149
|
+
setFilter((prev) => ({ ...prev, author }))
|
|
150
|
+
}, [])
|
|
151
|
+
|
|
152
|
+
const setLabel = useCallback((label: string | null) => {
|
|
153
|
+
setFilter((prev) => ({ ...prev, label }))
|
|
154
|
+
}, [])
|
|
155
|
+
|
|
156
|
+
const setSortBy = useCallback((sortBy: SortField) => {
|
|
157
|
+
setFilter((prev) => ({ ...prev, sortBy }))
|
|
158
|
+
}, [])
|
|
159
|
+
|
|
160
|
+
const toggleSortDirection = useCallback(() => {
|
|
161
|
+
setFilter((prev) => ({
|
|
162
|
+
...prev,
|
|
163
|
+
sortDirection: prev.sortDirection === 'asc' ? 'desc' : 'asc',
|
|
164
|
+
}))
|
|
165
|
+
}, [])
|
|
166
|
+
|
|
167
|
+
const clearFilters = useCallback(() => {
|
|
168
|
+
setFilter(defaultFilter)
|
|
169
|
+
}, [])
|
|
170
|
+
|
|
171
|
+
const hasActiveFilters =
|
|
172
|
+
filter.search !== '' ||
|
|
173
|
+
filter.repo !== null ||
|
|
174
|
+
filter.author !== null ||
|
|
175
|
+
filter.label !== null
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
filter,
|
|
179
|
+
filteredItems,
|
|
180
|
+
setSearch,
|
|
181
|
+
setRepo,
|
|
182
|
+
setAuthor,
|
|
183
|
+
setLabel,
|
|
184
|
+
setSortBy,
|
|
185
|
+
toggleSortDirection,
|
|
186
|
+
clearFilters,
|
|
187
|
+
hasActiveFilters,
|
|
188
|
+
availableRepos,
|
|
189
|
+
availableAuthors,
|
|
190
|
+
availableLabels,
|
|
191
|
+
}
|
|
192
|
+
}
|