@kitlangton/ghui 0.1.17 → 0.1.19
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/README.md +11 -4
- package/package.json +2 -1
- package/src/App.tsx +983 -238
- package/src/domain.ts +77 -10
- package/src/services/CommandRunner.ts +8 -1
- package/src/services/GitHubService.ts +291 -193
- package/src/ui/DetailsPane.tsx +26 -25
- package/src/ui/FooterHints.tsx +62 -6
- package/src/ui/PullRequestDiffPane.tsx +160 -49
- package/src/ui/PullRequestList.tsx +28 -10
- package/src/ui/colors.ts +41 -0
- package/src/ui/commentEditor.ts +126 -0
- package/src/ui/diff.ts +229 -16
- package/src/ui/modals.tsx +236 -9
- package/src/ui/primitives.tsx +2 -2
package/src/ui/DetailsPane.tsx
CHANGED
|
@@ -298,11 +298,13 @@ export const getDetailsPaneHeight = ({
|
|
|
298
298
|
|
|
299
299
|
export const DetailHeader = ({
|
|
300
300
|
pullRequest,
|
|
301
|
+
viewerUsername,
|
|
301
302
|
contentWidth,
|
|
302
303
|
paneWidth,
|
|
303
304
|
showChecks = false,
|
|
304
305
|
}: {
|
|
305
306
|
pullRequest: PullRequestItem
|
|
307
|
+
viewerUsername: string | null
|
|
306
308
|
contentWidth: number
|
|
307
309
|
paneWidth: number
|
|
308
310
|
showChecks?: boolean
|
|
@@ -319,34 +321,31 @@ export const DetailHeader = ({
|
|
|
319
321
|
: "no labels".length
|
|
320
322
|
const showStats = contentWidth - labelsWidth - statsText.length >= 2
|
|
321
323
|
const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
|
|
324
|
+
const opened = formatRelativeDate(pullRequest.createdAt)
|
|
325
|
+
const repo = shortRepoName(pullRequest.repository)
|
|
326
|
+
const author = viewerUsername && pullRequest.author !== viewerUsername ? ` by ${pullRequest.author}` : ""
|
|
327
|
+
const number = String(pullRequest.number)
|
|
328
|
+
const review = reviewLabel(pullRequest)
|
|
329
|
+
const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
|
|
330
|
+
const statusParts = [review, checks].filter((part): part is string => Boolean(part))
|
|
331
|
+
const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
|
|
332
|
+
const leftWidth = 1 + number.length + 1 + repo.length + author.length
|
|
333
|
+
const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
|
|
322
334
|
|
|
323
335
|
return (
|
|
324
336
|
<>
|
|
325
337
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
return (
|
|
338
|
-
<TextLine>
|
|
339
|
-
<span fg={colors.count}>#{number}</span>
|
|
340
|
-
<span fg={colors.muted}> {repo}</span>
|
|
341
|
-
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
342
|
-
{review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
|
|
343
|
-
{review && checks ? <span fg={colors.muted}> </span> : null}
|
|
344
|
-
{checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
|
|
345
|
-
{statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
|
|
346
|
-
<span fg={colors.muted}>{opened}</span>
|
|
347
|
-
</TextLine>
|
|
348
|
-
)
|
|
349
|
-
})()}
|
|
338
|
+
<TextLine>
|
|
339
|
+
<span fg={colors.count}>#{number}</span>
|
|
340
|
+
<span fg={colors.muted}> {repo}</span>
|
|
341
|
+
{author ? <span fg={colors.muted}>{author}</span> : null}
|
|
342
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
343
|
+
{review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
|
|
344
|
+
{review && checks ? <span fg={colors.muted}> </span> : null}
|
|
345
|
+
{checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
|
|
346
|
+
{statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
|
|
347
|
+
<span fg={colors.muted}>{opened}</span>
|
|
348
|
+
</TextLine>
|
|
350
349
|
</box>
|
|
351
350
|
<box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
352
351
|
{wrappedTitle.map((line, index) => (
|
|
@@ -482,6 +481,7 @@ export const LoadingPane = ({ content, width, height }: { content: DetailPlaceho
|
|
|
482
481
|
|
|
483
482
|
export const DetailsPane = ({
|
|
484
483
|
pullRequest,
|
|
484
|
+
viewerUsername,
|
|
485
485
|
contentWidth,
|
|
486
486
|
bodyLines = DETAIL_BODY_LINES,
|
|
487
487
|
paneWidth = contentWidth + 2,
|
|
@@ -491,6 +491,7 @@ export const DetailsPane = ({
|
|
|
491
491
|
themeId,
|
|
492
492
|
}: {
|
|
493
493
|
pullRequest: PullRequestItem | null
|
|
494
|
+
viewerUsername: string | null
|
|
494
495
|
contentWidth: number
|
|
495
496
|
bodyLines?: number
|
|
496
497
|
paneWidth?: number
|
|
@@ -505,7 +506,7 @@ export const DetailsPane = ({
|
|
|
505
506
|
<box flexDirection="column" height={contentHeight}>
|
|
506
507
|
{pullRequest ? (
|
|
507
508
|
<>
|
|
508
|
-
<DetailHeader pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
|
|
509
|
+
<DetailHeader pullRequest={pullRequest} viewerUsername={viewerUsername} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
|
|
509
510
|
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} themeId={themeId} />
|
|
510
511
|
</>
|
|
511
512
|
) : (
|
package/src/ui/FooterHints.tsx
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
|
+
import { Data } from "effect"
|
|
1
2
|
import { colors } from "./colors.js"
|
|
2
3
|
import { TextLine } from "./primitives.js"
|
|
3
4
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
readonly max: number
|
|
7
|
-
}
|
|
5
|
+
export type RetryProgress = Data.TaggedEnum<{
|
|
6
|
+
Idle: {}
|
|
7
|
+
Retrying: { readonly attempt: number; readonly max: number }
|
|
8
|
+
}>
|
|
9
|
+
|
|
10
|
+
export const RetryProgress = Data.taggedEnum<RetryProgress>()
|
|
11
|
+
export const initialRetryProgress: RetryProgress = RetryProgress.Idle()
|
|
8
12
|
|
|
9
13
|
export const FooterHints = ({
|
|
10
14
|
filterEditing,
|
|
11
15
|
showFilterClear,
|
|
12
16
|
detailFullView,
|
|
13
17
|
diffFullView,
|
|
18
|
+
diffCommentMode,
|
|
14
19
|
hasSelection,
|
|
15
20
|
canCloseSelection,
|
|
16
21
|
hasError,
|
|
@@ -22,12 +27,13 @@ export const FooterHints = ({
|
|
|
22
27
|
showFilterClear: boolean
|
|
23
28
|
detailFullView: boolean
|
|
24
29
|
diffFullView: boolean
|
|
30
|
+
diffCommentMode: boolean
|
|
25
31
|
hasSelection: boolean
|
|
26
32
|
canCloseSelection: boolean
|
|
27
33
|
hasError: boolean
|
|
28
34
|
isLoading: boolean
|
|
29
35
|
loadingIndicator: string
|
|
30
|
-
retryProgress: RetryProgress
|
|
36
|
+
retryProgress: RetryProgress
|
|
31
37
|
}) => {
|
|
32
38
|
if (filterEditing) {
|
|
33
39
|
return (
|
|
@@ -49,6 +55,28 @@ export const FooterHints = ({
|
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
if (diffFullView) {
|
|
58
|
+
if (diffCommentMode) {
|
|
59
|
+
return (
|
|
60
|
+
<TextLine>
|
|
61
|
+
<span fg={colors.count}>↑↓</span>
|
|
62
|
+
<span fg={colors.muted}> line </span>
|
|
63
|
+
<span fg={colors.count}>pgup/pgdn</span>
|
|
64
|
+
<span fg={colors.muted}> jump </span>
|
|
65
|
+
<span fg={colors.count}>←→</span>
|
|
66
|
+
<span fg={colors.muted}> side </span>
|
|
67
|
+
<span fg={colors.count}>enter</span>
|
|
68
|
+
<span fg={colors.muted}> open </span>
|
|
69
|
+
<span fg={colors.count}>a</span>
|
|
70
|
+
<span fg={colors.muted}> comment </span>
|
|
71
|
+
<span fg={colors.count}>c</span>
|
|
72
|
+
<span fg={colors.muted}> done </span>
|
|
73
|
+
<span fg={colors.count}>[]</span>
|
|
74
|
+
<span fg={colors.muted}> files </span>
|
|
75
|
+
<span fg={colors.count}>esc</span>
|
|
76
|
+
<span fg={colors.muted}> back</span>
|
|
77
|
+
</TextLine>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
52
80
|
return (
|
|
53
81
|
<TextLine>
|
|
54
82
|
<span fg={colors.count}>esc</span>
|
|
@@ -57,6 +85,8 @@ export const FooterHints = ({
|
|
|
57
85
|
<span fg={colors.muted}> view </span>
|
|
58
86
|
<span fg={colors.count}>w</span>
|
|
59
87
|
<span fg={colors.muted}> wrap </span>
|
|
88
|
+
<span fg={colors.count}>c</span>
|
|
89
|
+
<span fg={colors.muted}> comment </span>
|
|
60
90
|
<span fg={colors.count}>[]</span>
|
|
61
91
|
<span fg={colors.muted}> files </span>
|
|
62
92
|
<span fg={colors.count}>r</span>
|
|
@@ -74,6 +104,30 @@ export const FooterHints = ({
|
|
|
74
104
|
<TextLine>
|
|
75
105
|
<span fg={colors.count}>esc</span>
|
|
76
106
|
<span fg={colors.muted}> back </span>
|
|
107
|
+
<span fg={colors.count}>↑↓</span>
|
|
108
|
+
<span fg={colors.muted}> scroll </span>
|
|
109
|
+
<span fg={colors.count}>r</span>
|
|
110
|
+
<span fg={colors.muted}>{hasError ? " retry " : " refresh "}</span>
|
|
111
|
+
<span fg={colors.count}>t</span>
|
|
112
|
+
<span fg={colors.muted}> theme </span>
|
|
113
|
+
{hasSelection ? (
|
|
114
|
+
<>
|
|
115
|
+
<span fg={colors.count}>s</span>
|
|
116
|
+
<span fg={colors.muted}> state </span>
|
|
117
|
+
<span fg={colors.count}>d</span>
|
|
118
|
+
<span fg={colors.muted}> diff </span>
|
|
119
|
+
<span fg={colors.count}>l</span>
|
|
120
|
+
<span fg={colors.muted}> labels </span>
|
|
121
|
+
<span fg={colors.count}>m</span>
|
|
122
|
+
<span fg={colors.muted}> merge </span>
|
|
123
|
+
{canCloseSelection ? (
|
|
124
|
+
<>
|
|
125
|
+
<span fg={colors.count}>x</span>
|
|
126
|
+
<span fg={colors.muted}> close </span>
|
|
127
|
+
</>
|
|
128
|
+
) : null}
|
|
129
|
+
</>
|
|
130
|
+
) : null}
|
|
77
131
|
<span fg={colors.count}>o</span>
|
|
78
132
|
<span fg={colors.muted}> open </span>
|
|
79
133
|
<span fg={colors.count}>y</span>
|
|
@@ -86,6 +140,8 @@ export const FooterHints = ({
|
|
|
86
140
|
|
|
87
141
|
return (
|
|
88
142
|
<TextLine>
|
|
143
|
+
<span fg={colors.count}>tab</span>
|
|
144
|
+
<span fg={colors.muted}> queue </span>
|
|
89
145
|
<span fg={colors.count}>/</span>
|
|
90
146
|
<span fg={colors.muted}> filter </span>
|
|
91
147
|
<span fg={colors.count}>t</span>
|
|
@@ -96,7 +152,7 @@ export const FooterHints = ({
|
|
|
96
152
|
<span fg={colors.muted}> clear </span>
|
|
97
153
|
</>
|
|
98
154
|
) : null}
|
|
99
|
-
{retryProgress ? (
|
|
155
|
+
{retryProgress._tag === "Retrying" ? (
|
|
100
156
|
<>
|
|
101
157
|
<span fg={colors.status.pending}>retry</span>
|
|
102
158
|
<span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
1
|
+
import type { DiffRenderable, MouseEvent, ScrollBoxRenderable } from "@opentui/core"
|
|
2
2
|
import { useMemo, type Ref } from "react"
|
|
3
|
-
import type { PullRequestItem } from "../domain.js"
|
|
3
|
+
import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
|
|
4
4
|
import { colors, type ThemeId } from "./colors.js"
|
|
5
|
-
import { createDiffSyntaxStyle, diffStatText,
|
|
5
|
+
import { createDiffSyntaxStyle, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileAtLine, type DiffFileStats, type DiffView, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
|
|
6
6
|
import { LoadingPane, StatusCard } from "./DetailsPane.js"
|
|
7
7
|
import { Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
|
|
8
8
|
import { shortRepoName } from "./pullRequests.js"
|
|
@@ -27,36 +27,82 @@ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
|
|
|
27
27
|
)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
const FileStats = ({ stats }: { stats: DiffFileStats }) => {
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
{stats.additions > 0 ? <span fg={colors.status.passing}>{`+${stats.additions}`}</span> : null}
|
|
34
|
+
{stats.additions > 0 && stats.deletions > 0 ? <span fg={colors.muted}> </span> : null}
|
|
35
|
+
{stats.deletions > 0 ? <span fg={colors.status.failing}>{`-${stats.deletions}`}</span> : null}
|
|
36
|
+
</>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const FileHeader = ({
|
|
41
|
+
file,
|
|
42
|
+
index,
|
|
43
|
+
count,
|
|
44
|
+
width,
|
|
45
|
+
suffix = "",
|
|
46
|
+
suffixColor = colors.muted,
|
|
47
|
+
}: {
|
|
48
|
+
file: StackedDiffFilePatch["file"]
|
|
49
|
+
index: number
|
|
50
|
+
count: number
|
|
51
|
+
width: number
|
|
52
|
+
suffix?: string
|
|
53
|
+
suffixColor?: string
|
|
54
|
+
}) => {
|
|
55
|
+
const counter = `${index + 1}/${count}`
|
|
56
|
+
const stats = diffFileStats(file)
|
|
57
|
+
const statsText = diffFileStatsText(stats)
|
|
58
|
+
const nameWidth = Math.max(1, width - counter.length - statsText.length - suffix.length - 5)
|
|
59
|
+
return (
|
|
60
|
+
<TextLine>
|
|
61
|
+
<span fg={colors.muted}>{counter} </span>
|
|
62
|
+
<span fg={colors.text}>{fitCell(file.name, nameWidth)}</span>
|
|
63
|
+
{statsText ? <span fg={colors.muted}> </span> : null}
|
|
64
|
+
<FileStats stats={stats} />
|
|
65
|
+
{suffix ? <span fg={suffixColor}>{suffix}</span> : null}
|
|
66
|
+
</TextLine>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
30
70
|
export const PullRequestDiffPane = ({
|
|
31
71
|
pullRequest,
|
|
32
72
|
diffState,
|
|
33
|
-
|
|
73
|
+
stackedFiles,
|
|
74
|
+
scrollTop,
|
|
34
75
|
view,
|
|
35
76
|
wrapMode,
|
|
36
77
|
paneWidth,
|
|
37
78
|
height,
|
|
38
79
|
loadingIndicator,
|
|
39
80
|
scrollRef,
|
|
81
|
+
setDiffRef,
|
|
82
|
+
commentMode,
|
|
83
|
+
selectedCommentAnchor,
|
|
84
|
+
selectedCommentThread,
|
|
85
|
+
onSelectCommentLine,
|
|
40
86
|
themeId,
|
|
41
87
|
}: {
|
|
42
88
|
pullRequest: PullRequestItem | null
|
|
43
89
|
diffState: PullRequestDiffState | undefined
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
90
|
+
stackedFiles: readonly StackedDiffFilePatch[]
|
|
91
|
+
scrollTop: number
|
|
92
|
+
view: DiffView
|
|
93
|
+
wrapMode: DiffWrapMode
|
|
47
94
|
paneWidth: number
|
|
48
95
|
height: number
|
|
49
96
|
loadingIndicator: string
|
|
50
97
|
scrollRef: Ref<ScrollBoxRenderable>
|
|
98
|
+
setDiffRef: (index: number, diff: DiffRenderable | null) => void
|
|
99
|
+
commentMode: boolean
|
|
100
|
+
selectedCommentAnchor: StackedDiffCommentAnchor | null
|
|
101
|
+
selectedCommentThread: readonly PullRequestReviewComment[]
|
|
102
|
+
onSelectCommentLine: (renderLine: number, side: DiffCommentSide | null) => void
|
|
51
103
|
themeId: ThemeId
|
|
52
104
|
}) => {
|
|
53
|
-
const readyFiles = diffState?.
|
|
54
|
-
const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
|
|
55
|
-
const file = readyFiles[safeIndex] ?? null
|
|
56
|
-
const diffHeight = useMemo(
|
|
57
|
-
() => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
|
|
58
|
-
[file?.patch, view, wrapMode, paneWidth],
|
|
59
|
-
)
|
|
105
|
+
const readyFiles = diffState?._tag === "Ready" ? diffState.files : []
|
|
60
106
|
const syntaxStyle = useMemo(() => createDiffSyntaxStyle(), [themeId])
|
|
61
107
|
|
|
62
108
|
if (!pullRequest) {
|
|
@@ -68,7 +114,7 @@ export const PullRequestDiffPane = ({
|
|
|
68
114
|
const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
|
|
69
115
|
const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
|
|
70
116
|
|
|
71
|
-
if (!diffState || diffState.
|
|
117
|
+
if (!diffState || diffState._tag === "Loading") {
|
|
72
118
|
return (
|
|
73
119
|
<box height={height} flexDirection="column">
|
|
74
120
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
@@ -85,7 +131,7 @@ export const PullRequestDiffPane = ({
|
|
|
85
131
|
)
|
|
86
132
|
}
|
|
87
133
|
|
|
88
|
-
if (diffState.
|
|
134
|
+
if (diffState._tag === "Error") {
|
|
89
135
|
return (
|
|
90
136
|
<box height={height} flexDirection="column">
|
|
91
137
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
@@ -97,12 +143,44 @@ export const PullRequestDiffPane = ({
|
|
|
97
143
|
)
|
|
98
144
|
}
|
|
99
145
|
|
|
100
|
-
if (readyFiles.length === 0 ||
|
|
146
|
+
if (readyFiles.length === 0 || stackedFiles.length === 0) {
|
|
101
147
|
return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
|
|
102
148
|
}
|
|
103
149
|
|
|
104
|
-
const
|
|
105
|
-
const
|
|
150
|
+
const selectedSideLabel = selectedCommentAnchor?.side === "RIGHT" ? "right" : selectedCommentAnchor?.side === "LEFT" ? "left" : null
|
|
151
|
+
const commentPeek = commentMode && selectedCommentAnchor && selectedCommentThread.length > 0
|
|
152
|
+
? selectedCommentThread[selectedCommentThread.length - 1]!
|
|
153
|
+
: null
|
|
154
|
+
const commentPeekCount = selectedCommentThread.length === 1 ? "1 comment" : `${selectedCommentThread.length} comments`
|
|
155
|
+
const commentPeekBody = commentPeek?.body.split("\n")[0]?.trim() || "(empty comment)"
|
|
156
|
+
const commentPeekMeta = commentPeek && selectedCommentAnchor
|
|
157
|
+
? `${selectedSideLabel ?? "line"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line} ${commentPeek.author} ${commentPeekCount} enter thread a comment`
|
|
158
|
+
: ""
|
|
159
|
+
const stickyScrollTop = Math.max(0, Math.floor(scrollTop))
|
|
160
|
+
const stickyFile = stackedDiffFileAtLine(stackedFiles, stickyScrollTop) ?? stackedFiles[0]
|
|
161
|
+
const stickyArrayIndex = stickyFile ? stackedFiles.indexOf(stickyFile) : -1
|
|
162
|
+
const incomingStickyFile = stickyArrayIndex >= 0 ? stackedFiles[stickyArrayIndex + 1] : undefined
|
|
163
|
+
const incomingHeaderDistance = incomingStickyFile ? incomingStickyFile.headerLine - stickyScrollTop : Number.POSITIVE_INFINITY
|
|
164
|
+
const incomingFile = incomingHeaderDistance === 1 ? incomingStickyFile : undefined
|
|
165
|
+
const stickyCommentLabelFor = (stackedFile: StackedDiffFilePatch | undefined) => {
|
|
166
|
+
if (!commentMode) return ""
|
|
167
|
+
if (!selectedCommentAnchor) return " c no lines"
|
|
168
|
+
if (selectedCommentAnchor.fileIndex !== stackedFile?.index) return ""
|
|
169
|
+
return ` ${selectedCommentAnchor.side === "RIGHT" ? "right" : "left"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
|
|
170
|
+
}
|
|
171
|
+
const stickyCommentColor = selectedCommentAnchor?.side === "LEFT" ? colors.status.failing : colors.status.passing
|
|
172
|
+
const handleDiffMouseDown = function (this: ScrollBoxRenderable, event: MouseEvent) {
|
|
173
|
+
if (event.button !== 0) return
|
|
174
|
+
const localY = event.y - this.viewport.y
|
|
175
|
+
if (localY < 0 || localY >= this.viewport.height) return
|
|
176
|
+
const localX = event.x - this.viewport.x
|
|
177
|
+
const side = view === "split"
|
|
178
|
+
? localX < Math.floor(paneWidth / 2) ? "LEFT" : "RIGHT"
|
|
179
|
+
: null
|
|
180
|
+
onSelectCommentLine(Math.max(0, Math.floor(this.scrollTop + localY)), side)
|
|
181
|
+
event.preventDefault()
|
|
182
|
+
event.stopPropagation()
|
|
183
|
+
}
|
|
106
184
|
|
|
107
185
|
return (
|
|
108
186
|
<box height={height} flexDirection="column">
|
|
@@ -114,38 +192,71 @@ export const PullRequestDiffPane = ({
|
|
|
114
192
|
<DiffStats pullRequest={pullRequest} />
|
|
115
193
|
</TextLine>
|
|
116
194
|
</box>
|
|
117
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
118
|
-
<TextLine>
|
|
119
|
-
<span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
|
|
120
|
-
<span fg={colors.muted}> {fileCounter}</span>
|
|
121
|
-
</TextLine>
|
|
122
|
-
</box>
|
|
123
195
|
<Divider width={paneWidth} />
|
|
124
|
-
<scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false}>
|
|
125
|
-
|
|
126
|
-
key={`${pullRequest.url}-${
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
196
|
+
<scrollbox ref={scrollRef} focused={!commentMode} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
|
|
197
|
+
{stackedFiles.map((stackedFile) => (
|
|
198
|
+
<box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
|
|
199
|
+
{stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
|
|
200
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
201
|
+
<FileHeader file={stackedFile.file} index={stackedFile.index} count={readyFiles.length} width={paneWidth} />
|
|
202
|
+
</box>
|
|
203
|
+
<Divider width={paneWidth} />
|
|
204
|
+
<diff
|
|
205
|
+
ref={(diff: DiffRenderable | null) => setDiffRef(stackedFile.index, diff)}
|
|
206
|
+
diff={stackedFile.file.patch}
|
|
207
|
+
view={view}
|
|
208
|
+
syncScroll
|
|
209
|
+
filetype={stackedFile.file.filetype ?? "text"}
|
|
210
|
+
syntaxStyle={syntaxStyle}
|
|
211
|
+
showLineNumbers
|
|
212
|
+
wrapMode={wrapMode}
|
|
213
|
+
addedBg={colors.diff.addedBg}
|
|
214
|
+
removedBg={colors.diff.removedBg}
|
|
215
|
+
contextBg={colors.diff.contextBg}
|
|
216
|
+
addedSignColor={colors.status.passing}
|
|
217
|
+
removedSignColor={colors.status.failing}
|
|
218
|
+
lineNumberFg={colors.muted}
|
|
219
|
+
lineNumberBg={colors.diff.lineNumberBg}
|
|
220
|
+
addedLineNumberBg={colors.diff.addedLineNumberBg}
|
|
221
|
+
removedLineNumberBg={colors.diff.removedLineNumberBg}
|
|
222
|
+
selectionBg={colors.selectedBg}
|
|
223
|
+
selectionFg={colors.selectedText}
|
|
224
|
+
height={stackedFile.diffHeight}
|
|
225
|
+
style={{ flexShrink: 0 }}
|
|
226
|
+
/>
|
|
227
|
+
</box>
|
|
228
|
+
))}
|
|
148
229
|
</scrollbox>
|
|
230
|
+
{stickyFile ? (
|
|
231
|
+
<box position="absolute" top={2} left={0} width={paneWidth} height={2} zIndex={10} flexDirection="column" backgroundColor={colors.background}>
|
|
232
|
+
{incomingFile ? (
|
|
233
|
+
<>
|
|
234
|
+
<Divider width={paneWidth} />
|
|
235
|
+
<box height={1} paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
|
|
236
|
+
<FileHeader file={incomingFile.file} index={incomingFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(incomingFile)} suffixColor={stickyCommentColor} />
|
|
237
|
+
</box>
|
|
238
|
+
</>
|
|
239
|
+
) : (
|
|
240
|
+
<>
|
|
241
|
+
<box height={1} paddingLeft={1} paddingRight={1} backgroundColor={colors.background}>
|
|
242
|
+
<FileHeader file={stickyFile.file} index={stickyFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(stickyFile)} suffixColor={stickyCommentColor} />
|
|
243
|
+
</box>
|
|
244
|
+
<Divider width={paneWidth} />
|
|
245
|
+
</>
|
|
246
|
+
)}
|
|
247
|
+
</box>
|
|
248
|
+
) : null}
|
|
249
|
+
{commentPeek ? (
|
|
250
|
+
<>
|
|
251
|
+
<Divider width={paneWidth} />
|
|
252
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
253
|
+
<PlainLine text={fitCell(commentPeekMeta, Math.max(1, paneWidth - 2))} fg={colors.count} />
|
|
254
|
+
</box>
|
|
255
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
256
|
+
<PlainLine text={fitCell(commentPeekBody, Math.max(1, paneWidth - 2))} fg={colors.text} />
|
|
257
|
+
</box>
|
|
258
|
+
</>
|
|
259
|
+
) : null}
|
|
149
260
|
</box>
|
|
150
261
|
)
|
|
151
262
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
|
-
import type { PullRequestItem } from "../domain.js"
|
|
2
|
+
import type { LoadStatus, PullRequestItem } from "../domain.js"
|
|
3
3
|
import { daysOpen } from "../date.js"
|
|
4
4
|
import { colors } from "./colors.js"
|
|
5
5
|
import { fitCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
|
|
6
6
|
import { checkLabel, repoColor, reviewIcon, statusColor } from "./pullRequests.js"
|
|
7
7
|
|
|
8
|
-
export type LoadStatus = "loading" | "ready" | "error"
|
|
9
8
|
export type PullRequestGroups = Array<[string, PullRequestItem[]]>
|
|
10
9
|
|
|
11
10
|
const GROUP_ICON = "◆"
|
|
@@ -25,10 +24,26 @@ const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
|
|
|
25
24
|
return maxLen + 1
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
const
|
|
27
|
+
const MatchedCell = ({ text, width, query, align = "left" }: { text: string; width: number; query: string; align?: "left" | "right" }) => {
|
|
28
|
+
const fitted = fitCell(text, width, align)
|
|
29
|
+
const needle = query.trim().toLowerCase()
|
|
30
|
+
const index = needle.length > 0 ? fitted.toLowerCase().indexOf(needle) : -1
|
|
31
|
+
if (index < 0) return <span>{fitted}</span>
|
|
32
|
+
|
|
33
|
+
const end = Math.min(fitted.length, index + needle.length)
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
{index > 0 ? <span>{fitted.slice(0, index)}</span> : null}
|
|
37
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{fitted.slice(index, end)}</span>
|
|
38
|
+
{end < fitted.length ? <span>{fitted.slice(end)}</span> : null}
|
|
39
|
+
</>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const GroupTitle = ({ label, color, filterText }: { label: string; color: string; filterText: string }) => (
|
|
29
44
|
<TextLine>
|
|
30
45
|
<span fg={color}>{GROUP_ICON} </span>
|
|
31
|
-
<span fg={color} attributes={TextAttributes.BOLD}
|
|
46
|
+
<span fg={color} attributes={TextAttributes.BOLD}><MatchedCell text={label} width={label.length} query={filterText} /></span>
|
|
32
47
|
</TextLine>
|
|
33
48
|
)
|
|
34
49
|
|
|
@@ -37,12 +52,14 @@ const PullRequestRow = ({
|
|
|
37
52
|
selected,
|
|
38
53
|
contentWidth,
|
|
39
54
|
numWidth,
|
|
55
|
+
filterText,
|
|
40
56
|
onSelect,
|
|
41
57
|
}: {
|
|
42
58
|
pullRequest: PullRequestItem
|
|
43
59
|
selected: boolean
|
|
44
60
|
contentWidth: number
|
|
45
61
|
numWidth: number
|
|
62
|
+
filterText: string
|
|
46
63
|
onSelect: () => void
|
|
47
64
|
}) => {
|
|
48
65
|
const isClosed = pullRequest.state === "closed"
|
|
@@ -59,13 +76,13 @@ const PullRequestRow = ({
|
|
|
59
76
|
const checkColor = isMerged ? colors.status.passing : isClosed ? colors.muted : statusColor(pullRequest.checkStatus)
|
|
60
77
|
|
|
61
78
|
return (
|
|
62
|
-
<box height={1} onMouseDown={onSelect}>
|
|
63
|
-
<TextLine fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
|
|
79
|
+
<box width={contentWidth} height={1} onMouseDown={onSelect}>
|
|
80
|
+
<TextLine width={contentWidth} fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
|
|
64
81
|
<span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
|
|
65
82
|
<span> </span>
|
|
66
|
-
<span fg={numberColor}
|
|
83
|
+
<span fg={numberColor}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
|
|
67
84
|
<span> </span>
|
|
68
|
-
<span
|
|
85
|
+
<span><MatchedCell text={pullRequest.title} width={titleWidth} query={filterText} /></span>
|
|
69
86
|
<span fg={checkColor}>{fitCell(checkText, checkWidth, "right")}</span>
|
|
70
87
|
<span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
|
|
71
88
|
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
@@ -99,7 +116,7 @@ export const PullRequestList = ({
|
|
|
99
116
|
const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
|
|
100
117
|
|
|
101
118
|
return (
|
|
102
|
-
<box flexDirection="column">
|
|
119
|
+
<box width={contentWidth} flexDirection="column">
|
|
103
120
|
<SectionTitle title="PULL REQUESTS" />
|
|
104
121
|
{showFilterBar ? (
|
|
105
122
|
<TextLine>
|
|
@@ -115,7 +132,7 @@ export const PullRequestList = ({
|
|
|
115
132
|
const numWidth = groupNumberWidth(pullRequests)
|
|
116
133
|
return (
|
|
117
134
|
<box key={repo} flexDirection="column">
|
|
118
|
-
<GroupTitle label={repo} color={repoColor(repo)} />
|
|
135
|
+
<GroupTitle label={repo} color={repoColor(repo)} filterText={filterText} />
|
|
119
136
|
{pullRequests.map((pullRequest) => (
|
|
120
137
|
<PullRequestRow
|
|
121
138
|
key={pullRequest.url}
|
|
@@ -123,6 +140,7 @@ export const PullRequestList = ({
|
|
|
123
140
|
selected={pullRequest.url === selectedUrl}
|
|
124
141
|
contentWidth={contentWidth}
|
|
125
142
|
numWidth={numWidth}
|
|
143
|
+
filterText={filterText}
|
|
126
144
|
onSelect={() => onSelectPullRequest(pullRequest.url)}
|
|
127
145
|
/>
|
|
128
146
|
))}
|
package/src/ui/colors.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type ThemeId =
|
|
|
11
11
|
| "monokai"
|
|
12
12
|
| "solarized-dark"
|
|
13
13
|
| "everforest"
|
|
14
|
+
| "vesper"
|
|
14
15
|
| "opencode"
|
|
15
16
|
|
|
16
17
|
export interface ColorPalette {
|
|
@@ -566,6 +567,45 @@ const everforestColors: ColorPalette = {
|
|
|
566
567
|
},
|
|
567
568
|
}
|
|
568
569
|
|
|
570
|
+
const vesperColors: ColorPalette = {
|
|
571
|
+
background: "#101010",
|
|
572
|
+
modalBackground: "#1A1A1A",
|
|
573
|
+
text: "#FFFFFF",
|
|
574
|
+
muted: "#A0A0A0",
|
|
575
|
+
separator: "#282828",
|
|
576
|
+
accent: "#FFC799",
|
|
577
|
+
inlineCode: "#99FFE4",
|
|
578
|
+
error: "#FF8080",
|
|
579
|
+
selectedBg: "#232323",
|
|
580
|
+
selectedText: "#FFFFFF",
|
|
581
|
+
count: "#FFC799",
|
|
582
|
+
status: {
|
|
583
|
+
draft: "#FFC799",
|
|
584
|
+
approved: "#99FFE4",
|
|
585
|
+
changes: "#FF8080",
|
|
586
|
+
review: "#B0B0B0",
|
|
587
|
+
none: "#7E7E7E",
|
|
588
|
+
passing: "#99FFE4",
|
|
589
|
+
pending: "#FFC799",
|
|
590
|
+
failing: "#FF8080",
|
|
591
|
+
},
|
|
592
|
+
repos: {
|
|
593
|
+
opencode: "#FFC799",
|
|
594
|
+
"effect-smol": "#99FFE4",
|
|
595
|
+
"opencode-console": "#FFD1A8",
|
|
596
|
+
opencontrol: "#FFC799",
|
|
597
|
+
default: "#B0B0B0",
|
|
598
|
+
},
|
|
599
|
+
diff: {
|
|
600
|
+
addedBg: "#17312d",
|
|
601
|
+
removedBg: "#351f1f",
|
|
602
|
+
contextBg: "transparent",
|
|
603
|
+
lineNumberBg: "#101010",
|
|
604
|
+
addedLineNumberBg: "#142b28",
|
|
605
|
+
removedLineNumberBg: "#2f1c1c",
|
|
606
|
+
},
|
|
607
|
+
}
|
|
608
|
+
|
|
569
609
|
export const themeDefinitions: readonly ThemeDefinition[] = [
|
|
570
610
|
{ id: "ghui", name: "GHUI", description: "Warm parchment accents on a deep slate background", colors: ghuiColors },
|
|
571
611
|
{ id: "tokyo-night", name: "Tokyo Night", description: "Cool indigo surfaces with neon editor accents", colors: tokyoNightColors },
|
|
@@ -579,6 +619,7 @@ export const themeDefinitions: readonly ThemeDefinition[] = [
|
|
|
579
619
|
{ id: "monokai", name: "Monokai", description: "Classic dark olive with electric syntax colors", colors: monokaiColors },
|
|
580
620
|
{ id: "solarized-dark", name: "Solarized Dark", description: "Low-contrast blue-green base with calibrated accents", colors: solarizedDarkColors },
|
|
581
621
|
{ id: "everforest", name: "Everforest", description: "Soft green-gray forest tones with warm highlights", colors: everforestColors },
|
|
622
|
+
{ id: "vesper", name: "Vesper", description: "Minimal black surfaces with peach and aqua accents", colors: vesperColors },
|
|
582
623
|
{ id: "opencode", name: "OpenCode", description: "Charcoal panels with peach, violet, and blue highlights", colors: opencodeColors },
|
|
583
624
|
] as const
|
|
584
625
|
|