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,56 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { useTheme } from '../../theme/index'
|
|
4
|
+
|
|
5
|
+
interface PaginationBarProps {
|
|
6
|
+
readonly currentPage: number
|
|
7
|
+
readonly totalPages: number
|
|
8
|
+
readonly totalItems: number
|
|
9
|
+
readonly startIndex: number
|
|
10
|
+
readonly endIndex: number
|
|
11
|
+
readonly hasNextPage: boolean
|
|
12
|
+
readonly hasPrevPage: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function PaginationBar({
|
|
16
|
+
currentPage,
|
|
17
|
+
totalPages,
|
|
18
|
+
totalItems,
|
|
19
|
+
startIndex,
|
|
20
|
+
endIndex,
|
|
21
|
+
hasNextPage,
|
|
22
|
+
hasPrevPage,
|
|
23
|
+
}: PaginationBarProps): React.ReactElement {
|
|
24
|
+
const theme = useTheme()
|
|
25
|
+
|
|
26
|
+
if (totalPages <= 1) {
|
|
27
|
+
return (
|
|
28
|
+
<Box paddingX={1}>
|
|
29
|
+
<Text color={theme.colors.muted}>
|
|
30
|
+
{totalItems} item{totalItems !== 1 ? 's' : ''}
|
|
31
|
+
</Text>
|
|
32
|
+
</Box>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Box paddingX={1} gap={2}>
|
|
38
|
+
<Text color={theme.colors.muted}>
|
|
39
|
+
{startIndex + 1}-{endIndex} of {totalItems}
|
|
40
|
+
</Text>
|
|
41
|
+
<Box gap={1}>
|
|
42
|
+
<Text color={hasPrevPage ? theme.colors.accent : theme.colors.muted}>
|
|
43
|
+
{hasPrevPage ? '← [p]rev' : '← prev'}
|
|
44
|
+
</Text>
|
|
45
|
+
<Text color={theme.colors.muted}>│</Text>
|
|
46
|
+
<Text color={theme.colors.text}>
|
|
47
|
+
Page {currentPage}/{totalPages}
|
|
48
|
+
</Text>
|
|
49
|
+
<Text color={theme.colors.muted}>│</Text>
|
|
50
|
+
<Text color={hasNextPage ? theme.colors.accent : theme.colors.muted}>
|
|
51
|
+
{hasNextPage ? '[n]ext →' : 'next →'}
|
|
52
|
+
</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
</Box>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { Box, Text, useInput } from 'ink'
|
|
3
|
+
import SelectInput from 'ink-select-input'
|
|
4
|
+
import { useTheme } from '../../theme/index'
|
|
5
|
+
import { Modal } from './Modal'
|
|
6
|
+
import type { SortField, SortDirection } from '../../hooks/useFilter'
|
|
7
|
+
|
|
8
|
+
const SORT_OPTIONS: readonly { key: SortField; label: string }[] = [
|
|
9
|
+
{ key: 'updated', label: 'Last Updated' },
|
|
10
|
+
{ key: 'created', label: 'Created Date' },
|
|
11
|
+
{ key: 'repo', label: 'Repository' },
|
|
12
|
+
{ key: 'author', label: 'Author' },
|
|
13
|
+
{ key: 'title', label: 'Title' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
interface SortModalProps {
|
|
17
|
+
readonly currentSort: SortField
|
|
18
|
+
readonly sortDirection: SortDirection
|
|
19
|
+
readonly onSortChange: (sortBy: SortField) => void
|
|
20
|
+
readonly onSortDirectionToggle: () => void
|
|
21
|
+
readonly onClose: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function SortModal({
|
|
25
|
+
currentSort,
|
|
26
|
+
sortDirection,
|
|
27
|
+
onSortChange,
|
|
28
|
+
onSortDirectionToggle,
|
|
29
|
+
onClose,
|
|
30
|
+
}: SortModalProps): React.ReactElement {
|
|
31
|
+
const theme = useTheme()
|
|
32
|
+
|
|
33
|
+
useInput((input, key) => {
|
|
34
|
+
if (key.escape || input === 's') {
|
|
35
|
+
onClose()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const items = useMemo(
|
|
40
|
+
() =>
|
|
41
|
+
SORT_OPTIONS.map((option) => ({
|
|
42
|
+
label: `${option.label}${option.key === currentSort ? (sortDirection === 'desc' ? ' ↓' : ' ↑') : ''}`,
|
|
43
|
+
value: option.key,
|
|
44
|
+
})),
|
|
45
|
+
[currentSort, sortDirection],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const initialIndex = Math.max(
|
|
49
|
+
0,
|
|
50
|
+
SORT_OPTIONS.findIndex((o) => o.key === currentSort),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const handleSelect = (item: { label: string; value: SortField }) => {
|
|
54
|
+
if (item.value === currentSort) {
|
|
55
|
+
onSortDirectionToggle()
|
|
56
|
+
} else {
|
|
57
|
+
onSortChange(item.value)
|
|
58
|
+
}
|
|
59
|
+
onClose()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Modal>
|
|
64
|
+
<Box
|
|
65
|
+
flexDirection="column"
|
|
66
|
+
borderStyle="round"
|
|
67
|
+
borderColor={theme.colors.accent}
|
|
68
|
+
// @ts-ignore
|
|
69
|
+
backgroundColor={theme.colors.bg}
|
|
70
|
+
paddingX={2}
|
|
71
|
+
paddingY={1}
|
|
72
|
+
gap={1}
|
|
73
|
+
>
|
|
74
|
+
<Text color={theme.colors.accent} bold>
|
|
75
|
+
Sort by
|
|
76
|
+
</Text>
|
|
77
|
+
|
|
78
|
+
<SelectInput
|
|
79
|
+
items={items}
|
|
80
|
+
initialIndex={initialIndex}
|
|
81
|
+
onSelect={handleSelect}
|
|
82
|
+
isFocused={true}
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
<Text color={theme.colors.muted} dimColor>
|
|
86
|
+
j/k: move | Enter: select | Esc: close
|
|
87
|
+
</Text>
|
|
88
|
+
</Box>
|
|
89
|
+
</Modal>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react'
|
|
2
|
+
import { Text } from 'ink'
|
|
3
|
+
import { useTheme } from '../../theme/index'
|
|
4
|
+
|
|
5
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
6
|
+
|
|
7
|
+
interface SpinnerProps {
|
|
8
|
+
readonly label?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Spinner({ label }: SpinnerProps): React.ReactElement {
|
|
12
|
+
const theme = useTheme()
|
|
13
|
+
const [frame, setFrame] = useState(0)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const timer = setInterval(() => {
|
|
17
|
+
setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length)
|
|
18
|
+
}, 80)
|
|
19
|
+
|
|
20
|
+
return () => clearInterval(timer)
|
|
21
|
+
}, [])
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Text color={theme.colors.accent}>
|
|
25
|
+
{SPINNER_FRAMES[frame]} {label}
|
|
26
|
+
</Text>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { LoadingIndicator } from './LoadingIndicator'
|
|
2
|
+
export { EmptyState } from './EmptyState'
|
|
3
|
+
export { Divider } from './Divider'
|
|
4
|
+
export { Spinner } from './Spinner'
|
|
5
|
+
export { BorderedBox } from './BorderedBox'
|
|
6
|
+
export { Modal } from './Modal'
|
|
7
|
+
export { PaginationBar } from './PaginationBar'
|
|
8
|
+
export { FilterModal } from './FilterModal'
|
|
9
|
+
export { SortModal } from './SortModal'
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { useTheme } from '../../theme/index'
|
|
4
|
+
import { Divider } from '../common/Divider'
|
|
5
|
+
import { Modal } from '../common/Modal'
|
|
6
|
+
|
|
7
|
+
interface HelpModalProps {
|
|
8
|
+
readonly onClose: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const shortcuts = [
|
|
12
|
+
{ key: 'j / k', description: 'Move down / up' },
|
|
13
|
+
{ key: 'Enter', description: 'Select / Open' },
|
|
14
|
+
{ key: 'Tab', description: 'Switch focus panel' },
|
|
15
|
+
{ key: 'b', description: 'Toggle sidebar' },
|
|
16
|
+
{ key: '/', description: 'Search / Filter PRs' },
|
|
17
|
+
{ key: 's', description: 'Sort PRs' },
|
|
18
|
+
{ key: 'n / p', description: 'Next / Previous page' },
|
|
19
|
+
{ key: '1 / 2 / 3', description: 'Switch PR detail tabs' },
|
|
20
|
+
{ key: 'q', description: 'Back / Quit' },
|
|
21
|
+
{ key: '?', description: 'Toggle this help' },
|
|
22
|
+
{ key: 'Ctrl+c', description: 'Force quit' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export function HelpModal({ onClose }: HelpModalProps): React.ReactElement {
|
|
26
|
+
const theme = useTheme()
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Modal>
|
|
30
|
+
<Box
|
|
31
|
+
flexDirection="column"
|
|
32
|
+
borderStyle="round"
|
|
33
|
+
borderColor={theme.colors.accent}
|
|
34
|
+
// @ts-ignore
|
|
35
|
+
backgroundColor={theme.colors.bg}
|
|
36
|
+
paddingX={2}
|
|
37
|
+
paddingY={1}
|
|
38
|
+
gap={1}
|
|
39
|
+
>
|
|
40
|
+
<Text color={theme.colors.accent} bold>
|
|
41
|
+
Keyboard Shortcuts
|
|
42
|
+
</Text>
|
|
43
|
+
<Divider />
|
|
44
|
+
<Box flexDirection="column">
|
|
45
|
+
{shortcuts.map((s) => (
|
|
46
|
+
<Box key={s.key} gap={2}>
|
|
47
|
+
<Box width={16}>
|
|
48
|
+
<Text color={theme.colors.warning}>{s.key}</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
<Text color={theme.colors.text}>{s.description}</Text>
|
|
51
|
+
</Box>
|
|
52
|
+
))}
|
|
53
|
+
</Box>
|
|
54
|
+
<Divider />
|
|
55
|
+
<Text color={theme.colors.muted} dimColor>
|
|
56
|
+
Press ? to close
|
|
57
|
+
</Text>
|
|
58
|
+
</Box>
|
|
59
|
+
</Modal>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box } from 'ink'
|
|
3
|
+
import { useTheme } from '../../theme/index'
|
|
4
|
+
|
|
5
|
+
interface MainPanelProps {
|
|
6
|
+
readonly children: React.ReactNode
|
|
7
|
+
readonly isActive?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function MainPanel({
|
|
11
|
+
children,
|
|
12
|
+
isActive = false,
|
|
13
|
+
}: MainPanelProps): React.ReactElement {
|
|
14
|
+
const theme = useTheme()
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Box
|
|
18
|
+
flexDirection="column"
|
|
19
|
+
flexGrow={1}
|
|
20
|
+
borderStyle="single"
|
|
21
|
+
borderColor={isActive ? theme.colors.accent : theme.colors.border}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</Box>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { useTheme } from '../../theme/index'
|
|
4
|
+
|
|
5
|
+
export const SIDEBAR_ITEMS = [
|
|
6
|
+
'Involved',
|
|
7
|
+
'My PRs',
|
|
8
|
+
'For Review',
|
|
9
|
+
'This Repo',
|
|
10
|
+
'Settings',
|
|
11
|
+
] as const
|
|
12
|
+
|
|
13
|
+
export type SidebarItem = (typeof SIDEBAR_ITEMS)[number]
|
|
14
|
+
|
|
15
|
+
const sidebarIcons: Record<SidebarItem, string> = {
|
|
16
|
+
Involved: '◆',
|
|
17
|
+
'My PRs': '●',
|
|
18
|
+
'For Review': '◎',
|
|
19
|
+
'This Repo': '◈',
|
|
20
|
+
Settings: '⚙',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SidebarProps {
|
|
24
|
+
readonly selectedIndex: number
|
|
25
|
+
readonly visible: boolean
|
|
26
|
+
readonly isActive: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Sidebar({
|
|
30
|
+
selectedIndex,
|
|
31
|
+
visible,
|
|
32
|
+
isActive,
|
|
33
|
+
}: SidebarProps): React.ReactElement | null {
|
|
34
|
+
const theme = useTheme()
|
|
35
|
+
|
|
36
|
+
if (!visible) return null
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Box
|
|
40
|
+
flexDirection="column"
|
|
41
|
+
width={24}
|
|
42
|
+
borderStyle="single"
|
|
43
|
+
borderColor={isActive ? theme.colors.accent : theme.colors.border}
|
|
44
|
+
>
|
|
45
|
+
<Box paddingX={1} paddingY={0}>
|
|
46
|
+
<Text color={theme.colors.accent} bold={isActive} dimColor={!isActive}>
|
|
47
|
+
Navigation
|
|
48
|
+
</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
<Box flexDirection="column" paddingTop={1}>
|
|
51
|
+
{SIDEBAR_ITEMS.map((label, index) => {
|
|
52
|
+
const isSelected = index === selectedIndex
|
|
53
|
+
const icon = sidebarIcons[label]
|
|
54
|
+
return (
|
|
55
|
+
<Box key={label} paddingX={1}>
|
|
56
|
+
<Text
|
|
57
|
+
color={isSelected ? theme.colors.accent : theme.colors.text}
|
|
58
|
+
backgroundColor={isSelected ? theme.colors.selection : undefined}
|
|
59
|
+
bold={isSelected}
|
|
60
|
+
dimColor={!isActive && !isSelected}
|
|
61
|
+
>
|
|
62
|
+
{isSelected ? '▸ ' : ' '}
|
|
63
|
+
{icon} {label}
|
|
64
|
+
</Text>
|
|
65
|
+
</Box>
|
|
66
|
+
)
|
|
67
|
+
})}
|
|
68
|
+
</Box>
|
|
69
|
+
</Box>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { Spinner } from '../common/Spinner'
|
|
4
|
+
import { useTheme } from '../../theme/index'
|
|
5
|
+
import { useLoading } from '../../hooks/useLoading'
|
|
6
|
+
import type { Panel } from '../../hooks/useActivePanel'
|
|
7
|
+
|
|
8
|
+
const PANEL_HINTS: Record<Panel, string> = {
|
|
9
|
+
sidebar: 'j/k:nav gg/G:top/bottom Enter:select Tab:list b:toggle ?:help q:quit',
|
|
10
|
+
list: 'j/k:nav gg/G:top/bottom Enter:detail Esc:sidebar Tab:next ?:help q:quit',
|
|
11
|
+
detail: 'j/k:scroll Tab:tabs Esc:list ?:help q:quit',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface StatusBarProps {
|
|
15
|
+
readonly activePanel?: Panel
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function StatusBar({ activePanel = 'sidebar' }: StatusBarProps): React.ReactElement {
|
|
19
|
+
const theme = useTheme()
|
|
20
|
+
const loadingState = useLoading()
|
|
21
|
+
const hints = PANEL_HINTS[activePanel]
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Box
|
|
25
|
+
height={1}
|
|
26
|
+
width="100%"
|
|
27
|
+
justifyContent="space-between"
|
|
28
|
+
paddingX={1}
|
|
29
|
+
>
|
|
30
|
+
<Box>
|
|
31
|
+
{loadingState.isLoading ? (
|
|
32
|
+
<Spinner label={loadingState.message ?? 'Loading...'} />
|
|
33
|
+
) : (
|
|
34
|
+
<Text color={theme.colors.success}>Ready</Text>
|
|
35
|
+
)}
|
|
36
|
+
</Box>
|
|
37
|
+
<Box gap={1}>
|
|
38
|
+
<Text color={theme.colors.muted}>{hints}</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
</Box>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react'
|
|
2
|
+
import { Box, Text, useInput } from 'ink'
|
|
3
|
+
import { TextInput } from '@inkjs/ui'
|
|
4
|
+
import { useTheme } from '../../theme/index'
|
|
5
|
+
import { useInputFocus } from '../../hooks/useInputFocus'
|
|
6
|
+
import { Modal } from '../common/Modal'
|
|
7
|
+
|
|
8
|
+
interface TokenInputModalProps {
|
|
9
|
+
readonly onClose: () => void
|
|
10
|
+
readonly onSubmit: (token: string) => void
|
|
11
|
+
readonly error?: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function TokenInputModal({
|
|
15
|
+
onSubmit,
|
|
16
|
+
error,
|
|
17
|
+
}: TokenInputModalProps): React.ReactElement {
|
|
18
|
+
const theme = useTheme()
|
|
19
|
+
const { setInputActive } = useInputFocus()
|
|
20
|
+
const [value, setValue] = useState('')
|
|
21
|
+
|
|
22
|
+
// Disable global shortcuts while this modal is open
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setInputActive(true)
|
|
25
|
+
return () => setInputActive(false)
|
|
26
|
+
}, [setInputActive])
|
|
27
|
+
|
|
28
|
+
const handleSubmit = (): void => {
|
|
29
|
+
const trimmed = value.trim()
|
|
30
|
+
if (trimmed) {
|
|
31
|
+
onSubmit(trimmed)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
useInput((_input, key) => {
|
|
36
|
+
if (key.return) {
|
|
37
|
+
handleSubmit()
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Modal>
|
|
43
|
+
<Box
|
|
44
|
+
flexDirection="column"
|
|
45
|
+
borderStyle="round"
|
|
46
|
+
borderColor={theme.colors.accent}
|
|
47
|
+
// @ts-ignore
|
|
48
|
+
backgroundColor={theme.colors.bg}
|
|
49
|
+
paddingX={2}
|
|
50
|
+
paddingY={1}
|
|
51
|
+
gap={1}
|
|
52
|
+
>
|
|
53
|
+
<Text color={theme.colors.accent} bold>
|
|
54
|
+
GitHub Token Required
|
|
55
|
+
</Text>
|
|
56
|
+
<Box flexDirection="column">
|
|
57
|
+
<Text color={theme.colors.text}>
|
|
58
|
+
No GitHub token found in your environment.
|
|
59
|
+
</Text>
|
|
60
|
+
<Text color={theme.colors.muted}>
|
|
61
|
+
Please enter your GitHub Personal Access Token:
|
|
62
|
+
</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
{error && <Text color={theme.colors.error}>Error: {error}</Text>}
|
|
65
|
+
<Box
|
|
66
|
+
borderStyle="single"
|
|
67
|
+
borderColor={theme.colors.border}
|
|
68
|
+
paddingX={1}
|
|
69
|
+
width={50}
|
|
70
|
+
>
|
|
71
|
+
<TextInput defaultValue={value} onChange={setValue} />
|
|
72
|
+
</Box>
|
|
73
|
+
<Box flexDirection="column">
|
|
74
|
+
<Text color={theme.colors.muted} dimColor>
|
|
75
|
+
The token will be saved to ~/.config/lazyreview/.token
|
|
76
|
+
</Text>
|
|
77
|
+
<Text color={theme.colors.muted} dimColor>
|
|
78
|
+
Press Enter to submit, Ctrl+C to quit.
|
|
79
|
+
</Text>
|
|
80
|
+
</Box>
|
|
81
|
+
<Box flexDirection="column" marginTop={1}>
|
|
82
|
+
<Text color={theme.colors.info}>
|
|
83
|
+
Get a token at: github.com/settings/tokens
|
|
84
|
+
</Text>
|
|
85
|
+
<Text color={theme.colors.muted}>
|
|
86
|
+
Required scopes: repo, read:user
|
|
87
|
+
</Text>
|
|
88
|
+
</Box>
|
|
89
|
+
</Box>
|
|
90
|
+
</Modal>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { useTheme } from '../../theme/index'
|
|
4
|
+
|
|
5
|
+
interface TopBarProps {
|
|
6
|
+
readonly username: string
|
|
7
|
+
readonly provider: string
|
|
8
|
+
readonly repoPath?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function TopBar({
|
|
12
|
+
username,
|
|
13
|
+
provider,
|
|
14
|
+
repoPath,
|
|
15
|
+
}: TopBarProps): React.ReactElement {
|
|
16
|
+
const theme = useTheme()
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Box
|
|
20
|
+
height={1}
|
|
21
|
+
width="100%"
|
|
22
|
+
justifyContent="space-between"
|
|
23
|
+
paddingX={1}
|
|
24
|
+
marginTop={0.6}
|
|
25
|
+
>
|
|
26
|
+
<Box gap={1}>
|
|
27
|
+
<Text color={theme.colors.accent} bold>
|
|
28
|
+
LazyReview
|
|
29
|
+
</Text>
|
|
30
|
+
{repoPath && (
|
|
31
|
+
<>
|
|
32
|
+
<Text color={theme.colors.muted}>│</Text>
|
|
33
|
+
<Text color={theme.colors.text}>{repoPath}</Text>
|
|
34
|
+
</>
|
|
35
|
+
)}
|
|
36
|
+
</Box>
|
|
37
|
+
<Box gap={1}>
|
|
38
|
+
<Text color={theme.colors.muted}>{provider}</Text>
|
|
39
|
+
<Text color={theme.colors.muted}>│</Text>
|
|
40
|
+
<Text color={theme.colors.secondary}>{username}</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
</Box>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react'
|
|
2
|
+
import { Box, Text, useStdout } from 'ink'
|
|
3
|
+
import { ScrollList, type ScrollListRef } from 'ink-scroll-list'
|
|
4
|
+
import { useTheme } from '../../theme/index'
|
|
5
|
+
import { useListNavigation } from '../../hooks/useListNavigation'
|
|
6
|
+
import type { Comment } from '../../models/comment'
|
|
7
|
+
import { timeAgo } from '../../utils/date'
|
|
8
|
+
import { EmptyState } from '../common/EmptyState'
|
|
9
|
+
|
|
10
|
+
interface CommentsTabProps {
|
|
11
|
+
readonly comments: readonly Comment[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CommentItemProps {
|
|
15
|
+
readonly item: Comment
|
|
16
|
+
readonly isFocus: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function CommentItem({ item, isFocus }: CommentItemProps): React.ReactElement {
|
|
20
|
+
const theme = useTheme()
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Box
|
|
24
|
+
flexDirection="column"
|
|
25
|
+
paddingX={1}
|
|
26
|
+
paddingY={0}
|
|
27
|
+
borderStyle={isFocus ? 'single' : undefined}
|
|
28
|
+
borderColor={isFocus ? theme.colors.accent : undefined}
|
|
29
|
+
>
|
|
30
|
+
<Box gap={1}>
|
|
31
|
+
<Text color={theme.colors.secondary} bold>
|
|
32
|
+
{item.user.login}
|
|
33
|
+
</Text>
|
|
34
|
+
<Text color={theme.colors.muted}>{timeAgo(item.created_at)}</Text>
|
|
35
|
+
{item.path && (
|
|
36
|
+
<>
|
|
37
|
+
<Text color={theme.colors.muted}>on</Text>
|
|
38
|
+
<Text color={theme.colors.info}>{item.path}</Text>
|
|
39
|
+
{item.line && (
|
|
40
|
+
<Text color={theme.colors.muted}>:{item.line}</Text>
|
|
41
|
+
)}
|
|
42
|
+
</>
|
|
43
|
+
)}
|
|
44
|
+
</Box>
|
|
45
|
+
<Box paddingLeft={2}>
|
|
46
|
+
<Text color={theme.colors.text} wrap="wrap">
|
|
47
|
+
{item.body}
|
|
48
|
+
</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
</Box>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function CommentsTab({
|
|
55
|
+
comments,
|
|
56
|
+
}: CommentsTabProps): React.ReactElement {
|
|
57
|
+
const { stdout } = useStdout()
|
|
58
|
+
const listRef = useRef<ScrollListRef>(null)
|
|
59
|
+
const viewportHeight = Math.max(1, Math.floor((stdout?.rows ?? 24) - 8) / 4)
|
|
60
|
+
|
|
61
|
+
const { selectedIndex } = useListNavigation({
|
|
62
|
+
itemCount: comments.length,
|
|
63
|
+
viewportHeight,
|
|
64
|
+
isActive: true,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const handleResize = (): void => listRef.current?.remeasure()
|
|
69
|
+
stdout?.on('resize', handleResize)
|
|
70
|
+
return () => {
|
|
71
|
+
stdout?.off('resize', handleResize)
|
|
72
|
+
}
|
|
73
|
+
}, [stdout])
|
|
74
|
+
|
|
75
|
+
if (comments.length === 0) {
|
|
76
|
+
return <EmptyState message="No comments yet" />
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden" height={viewportHeight}>
|
|
81
|
+
<ScrollList ref={listRef} selectedIndex={selectedIndex} scrollAlignment="auto">
|
|
82
|
+
{comments.map((comment, index) => (
|
|
83
|
+
<CommentItem
|
|
84
|
+
key={comment.id}
|
|
85
|
+
item={comment}
|
|
86
|
+
isFocus={index === selectedIndex}
|
|
87
|
+
/>
|
|
88
|
+
))}
|
|
89
|
+
</ScrollList>
|
|
90
|
+
</Box>
|
|
91
|
+
)
|
|
92
|
+
}
|