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