@kitlangton/ghui 0.1.17 → 0.1.18
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 +879 -141
- package/src/domain.ts +46 -0
- package/src/services/CommandRunner.ts +8 -1
- package/src/services/GitHubService.ts +280 -184
- package/src/ui/DetailsPane.tsx +26 -25
- package/src/ui/FooterHints.tsx +52 -0
- package/src/ui/PullRequestDiffPane.tsx +105 -33
- package/src/ui/PullRequestList.tsx +28 -9
- package/src/ui/colors.ts +41 -0
- package/src/ui/commentEditor.ts +126 -0
- package/src/ui/diff.ts +204 -7
- package/src/ui/modals.tsx +236 -9
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
|
@@ -11,6 +11,7 @@ export const FooterHints = ({
|
|
|
11
11
|
showFilterClear,
|
|
12
12
|
detailFullView,
|
|
13
13
|
diffFullView,
|
|
14
|
+
diffCommentMode,
|
|
14
15
|
hasSelection,
|
|
15
16
|
canCloseSelection,
|
|
16
17
|
hasError,
|
|
@@ -22,6 +23,7 @@ export const FooterHints = ({
|
|
|
22
23
|
showFilterClear: boolean
|
|
23
24
|
detailFullView: boolean
|
|
24
25
|
diffFullView: boolean
|
|
26
|
+
diffCommentMode: boolean
|
|
25
27
|
hasSelection: boolean
|
|
26
28
|
canCloseSelection: boolean
|
|
27
29
|
hasError: boolean
|
|
@@ -49,6 +51,28 @@ export const FooterHints = ({
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
if (diffFullView) {
|
|
54
|
+
if (diffCommentMode) {
|
|
55
|
+
return (
|
|
56
|
+
<TextLine>
|
|
57
|
+
<span fg={colors.count}>↑↓</span>
|
|
58
|
+
<span fg={colors.muted}> line </span>
|
|
59
|
+
<span fg={colors.count}>pgup/pgdn</span>
|
|
60
|
+
<span fg={colors.muted}> jump </span>
|
|
61
|
+
<span fg={colors.count}>←→</span>
|
|
62
|
+
<span fg={colors.muted}> side </span>
|
|
63
|
+
<span fg={colors.count}>enter</span>
|
|
64
|
+
<span fg={colors.muted}> open </span>
|
|
65
|
+
<span fg={colors.count}>a</span>
|
|
66
|
+
<span fg={colors.muted}> comment </span>
|
|
67
|
+
<span fg={colors.count}>c</span>
|
|
68
|
+
<span fg={colors.muted}> done </span>
|
|
69
|
+
<span fg={colors.count}>[]</span>
|
|
70
|
+
<span fg={colors.muted}> files </span>
|
|
71
|
+
<span fg={colors.count}>esc</span>
|
|
72
|
+
<span fg={colors.muted}> back</span>
|
|
73
|
+
</TextLine>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
52
76
|
return (
|
|
53
77
|
<TextLine>
|
|
54
78
|
<span fg={colors.count}>esc</span>
|
|
@@ -57,6 +81,8 @@ export const FooterHints = ({
|
|
|
57
81
|
<span fg={colors.muted}> view </span>
|
|
58
82
|
<span fg={colors.count}>w</span>
|
|
59
83
|
<span fg={colors.muted}> wrap </span>
|
|
84
|
+
<span fg={colors.count}>c</span>
|
|
85
|
+
<span fg={colors.muted}> comment </span>
|
|
60
86
|
<span fg={colors.count}>[]</span>
|
|
61
87
|
<span fg={colors.muted}> files </span>
|
|
62
88
|
<span fg={colors.count}>r</span>
|
|
@@ -74,6 +100,30 @@ export const FooterHints = ({
|
|
|
74
100
|
<TextLine>
|
|
75
101
|
<span fg={colors.count}>esc</span>
|
|
76
102
|
<span fg={colors.muted}> back </span>
|
|
103
|
+
<span fg={colors.count}>↑↓</span>
|
|
104
|
+
<span fg={colors.muted}> scroll </span>
|
|
105
|
+
<span fg={colors.count}>r</span>
|
|
106
|
+
<span fg={colors.muted}>{hasError ? " retry " : " refresh "}</span>
|
|
107
|
+
<span fg={colors.count}>t</span>
|
|
108
|
+
<span fg={colors.muted}> theme </span>
|
|
109
|
+
{hasSelection ? (
|
|
110
|
+
<>
|
|
111
|
+
<span fg={colors.count}>s</span>
|
|
112
|
+
<span fg={colors.muted}> state </span>
|
|
113
|
+
<span fg={colors.count}>d</span>
|
|
114
|
+
<span fg={colors.muted}> diff </span>
|
|
115
|
+
<span fg={colors.count}>l</span>
|
|
116
|
+
<span fg={colors.muted}> labels </span>
|
|
117
|
+
<span fg={colors.count}>m</span>
|
|
118
|
+
<span fg={colors.muted}> merge </span>
|
|
119
|
+
{canCloseSelection ? (
|
|
120
|
+
<>
|
|
121
|
+
<span fg={colors.count}>x</span>
|
|
122
|
+
<span fg={colors.muted}> close </span>
|
|
123
|
+
</>
|
|
124
|
+
) : null}
|
|
125
|
+
</>
|
|
126
|
+
) : null}
|
|
77
127
|
<span fg={colors.count}>o</span>
|
|
78
128
|
<span fg={colors.muted}> open </span>
|
|
79
129
|
<span fg={colors.count}>y</span>
|
|
@@ -86,6 +136,8 @@ export const FooterHints = ({
|
|
|
86
136
|
|
|
87
137
|
return (
|
|
88
138
|
<TextLine>
|
|
139
|
+
<span fg={colors.count}>tab</span>
|
|
140
|
+
<span fg={colors.muted}> queue </span>
|
|
89
141
|
<span fg={colors.count}>/</span>
|
|
90
142
|
<span fg={colors.muted}> filter </span>
|
|
91
143
|
<span fg={colors.count}>t</span>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
1
|
+
import type { DiffRenderable, ScrollBoxRenderable } from "@opentui/core"
|
|
2
2
|
import { useMemo, type Ref } from "react"
|
|
3
|
-
import type { PullRequestItem } from "../domain.js"
|
|
3
|
+
import type { PullRequestItem, PullRequestReviewComment } from "../domain.js"
|
|
4
4
|
import { colors, type ThemeId } from "./colors.js"
|
|
5
|
-
import { createDiffSyntaxStyle, diffStatText,
|
|
5
|
+
import { createDiffSyntaxStyle, diffFileStats, diffFileStatText, diffStatText, safeDiffFileIndex, type DiffCommentAnchor, type PullRequestDiffState, 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,9 +27,35 @@ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
|
|
|
27
27
|
)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
const FileStats = ({ file }: { file: StackedDiffFilePatch["file"] }) => {
|
|
31
|
+
const stats = diffFileStats(file)
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
{stats.additions > 0 ? <span fg={colors.status.passing}>{`+${stats.additions}`}</span> : null}
|
|
35
|
+
{stats.additions > 0 && stats.deletions > 0 ? <span fg={colors.muted}> </span> : null}
|
|
36
|
+
{stats.deletions > 0 ? <span fg={colors.status.failing}>{`-${stats.deletions}`}</span> : null}
|
|
37
|
+
</>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const FileHeader = ({ file, index, count, width }: { file: StackedDiffFilePatch["file"]; index: number; count: number; width: number }) => {
|
|
42
|
+
const counter = `${index + 1}/${count}`
|
|
43
|
+
const statsText = diffFileStatText(file)
|
|
44
|
+
const nameWidth = Math.max(1, width - counter.length - statsText.length - 5)
|
|
45
|
+
return (
|
|
46
|
+
<TextLine>
|
|
47
|
+
<span fg={colors.muted}>{counter} </span>
|
|
48
|
+
<span fg={colors.text}>{fitCell(file.name, nameWidth)}</span>
|
|
49
|
+
{statsText ? <span fg={colors.muted}> </span> : null}
|
|
50
|
+
<FileStats file={file} />
|
|
51
|
+
</TextLine>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
30
55
|
export const PullRequestDiffPane = ({
|
|
31
56
|
pullRequest,
|
|
32
57
|
diffState,
|
|
58
|
+
stackedFiles,
|
|
33
59
|
fileIndex,
|
|
34
60
|
view,
|
|
35
61
|
wrapMode,
|
|
@@ -37,10 +63,16 @@ export const PullRequestDiffPane = ({
|
|
|
37
63
|
height,
|
|
38
64
|
loadingIndicator,
|
|
39
65
|
scrollRef,
|
|
66
|
+
setDiffRef,
|
|
67
|
+
commentMode,
|
|
68
|
+
selectedCommentAnchor,
|
|
69
|
+
selectedCommentThread,
|
|
70
|
+
commentCount,
|
|
40
71
|
themeId,
|
|
41
72
|
}: {
|
|
42
73
|
pullRequest: PullRequestItem | null
|
|
43
74
|
diffState: PullRequestDiffState | undefined
|
|
75
|
+
stackedFiles: readonly StackedDiffFilePatch[]
|
|
44
76
|
fileIndex: number
|
|
45
77
|
view: "unified" | "split"
|
|
46
78
|
wrapMode: "none" | "word"
|
|
@@ -48,15 +80,16 @@ export const PullRequestDiffPane = ({
|
|
|
48
80
|
height: number
|
|
49
81
|
loadingIndicator: string
|
|
50
82
|
scrollRef: Ref<ScrollBoxRenderable>
|
|
83
|
+
setDiffRef: (index: number, diff: DiffRenderable | null) => void
|
|
84
|
+
commentMode: boolean
|
|
85
|
+
selectedCommentAnchor: DiffCommentAnchor | null
|
|
86
|
+
selectedCommentThread: readonly PullRequestReviewComment[]
|
|
87
|
+
commentCount: number
|
|
51
88
|
themeId: ThemeId
|
|
52
89
|
}) => {
|
|
53
90
|
const readyFiles = diffState?.status === "ready" ? diffState.files : []
|
|
54
|
-
const safeIndex = readyFiles
|
|
91
|
+
const safeIndex = safeDiffFileIndex(readyFiles, fileIndex)
|
|
55
92
|
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
|
-
)
|
|
60
93
|
const syntaxStyle = useMemo(() => createDiffSyntaxStyle(), [themeId])
|
|
61
94
|
|
|
62
95
|
if (!pullRequest) {
|
|
@@ -102,7 +135,23 @@ export const PullRequestDiffPane = ({
|
|
|
102
135
|
}
|
|
103
136
|
|
|
104
137
|
const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
|
|
105
|
-
const
|
|
138
|
+
const currentFileStatsText = file ? diffFileStatText(file) : ""
|
|
139
|
+
const selectedSideLabel = selectedCommentAnchor?.side === "RIGHT" ? "right" : selectedCommentAnchor?.side === "LEFT" ? "left" : null
|
|
140
|
+
const commentLabel = commentMode && selectedCommentAnchor
|
|
141
|
+
? ` ${selectedSideLabel} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
|
|
142
|
+
: commentMode ? " c no lines" : commentCount > 0 ? ` ● ${commentCount}` : ""
|
|
143
|
+
const commentLabelColor = commentMode && selectedCommentAnchor?.side === "LEFT"
|
|
144
|
+
? colors.status.failing
|
|
145
|
+
: commentMode ? colors.status.passing : colors.status.passing
|
|
146
|
+
const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - currentFileStatsText.length - commentLabel.length - 4)
|
|
147
|
+
const commentPeek = commentMode && selectedCommentAnchor && selectedCommentThread.length > 0
|
|
148
|
+
? selectedCommentThread[selectedCommentThread.length - 1]!
|
|
149
|
+
: null
|
|
150
|
+
const commentPeekCount = selectedCommentThread.length === 1 ? "1 comment" : `${selectedCommentThread.length} comments`
|
|
151
|
+
const commentPeekBody = commentPeek?.body.split("\n")[0]?.trim() || "(empty comment)"
|
|
152
|
+
const commentPeekMeta = commentPeek && selectedCommentAnchor
|
|
153
|
+
? `${selectedSideLabel ?? "line"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line} ${commentPeek.author} ${commentPeekCount} enter thread a comment`
|
|
154
|
+
: ""
|
|
106
155
|
|
|
107
156
|
return (
|
|
108
157
|
<box height={height} flexDirection="column">
|
|
@@ -118,34 +167,57 @@ export const PullRequestDiffPane = ({
|
|
|
118
167
|
<TextLine>
|
|
119
168
|
<span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
|
|
120
169
|
<span fg={colors.muted}> {fileCounter}</span>
|
|
170
|
+
{currentFileStatsText ? <span fg={colors.muted}> </span> : null}
|
|
171
|
+
<FileStats file={file} />
|
|
172
|
+
{commentLabel ? <span fg={commentLabelColor}>{commentLabel}</span> : null}
|
|
121
173
|
</TextLine>
|
|
122
174
|
</box>
|
|
123
175
|
<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
|
-
|
|
176
|
+
<scrollbox ref={scrollRef} focused={!commentMode} flexGrow={1} scrollY scrollX={false}>
|
|
177
|
+
{stackedFiles.map((stackedFile) => (
|
|
178
|
+
<box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
|
|
179
|
+
{stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
|
|
180
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
181
|
+
<FileHeader file={stackedFile.file} index={stackedFile.index} count={readyFiles.length} width={paneWidth} />
|
|
182
|
+
</box>
|
|
183
|
+
<Divider width={paneWidth} />
|
|
184
|
+
<diff
|
|
185
|
+
ref={(diff: DiffRenderable | null) => setDiffRef(stackedFile.index, diff)}
|
|
186
|
+
diff={stackedFile.file.patch}
|
|
187
|
+
view={view}
|
|
188
|
+
syncScroll
|
|
189
|
+
filetype={stackedFile.file.filetype ?? "text"}
|
|
190
|
+
syntaxStyle={syntaxStyle}
|
|
191
|
+
showLineNumbers
|
|
192
|
+
wrapMode={wrapMode}
|
|
193
|
+
addedBg={colors.diff.addedBg}
|
|
194
|
+
removedBg={colors.diff.removedBg}
|
|
195
|
+
contextBg={colors.diff.contextBg}
|
|
196
|
+
addedSignColor={colors.status.passing}
|
|
197
|
+
removedSignColor={colors.status.failing}
|
|
198
|
+
lineNumberFg={colors.muted}
|
|
199
|
+
lineNumberBg={colors.diff.lineNumberBg}
|
|
200
|
+
addedLineNumberBg={colors.diff.addedLineNumberBg}
|
|
201
|
+
removedLineNumberBg={colors.diff.removedLineNumberBg}
|
|
202
|
+
selectionBg={colors.selectedBg}
|
|
203
|
+
selectionFg={colors.selectedText}
|
|
204
|
+
height={stackedFile.diffHeight}
|
|
205
|
+
style={{ flexShrink: 0 }}
|
|
206
|
+
/>
|
|
207
|
+
</box>
|
|
208
|
+
))}
|
|
148
209
|
</scrollbox>
|
|
210
|
+
{commentPeek ? (
|
|
211
|
+
<>
|
|
212
|
+
<Divider width={paneWidth} />
|
|
213
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
214
|
+
<PlainLine text={fitCell(commentPeekMeta, Math.max(1, paneWidth - 2))} fg={colors.count} />
|
|
215
|
+
</box>
|
|
216
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
217
|
+
<PlainLine text={fitCell(commentPeekBody, Math.max(1, paneWidth - 2))} fg={colors.text} />
|
|
218
|
+
</box>
|
|
219
|
+
</>
|
|
220
|
+
) : null}
|
|
149
221
|
</box>
|
|
150
222
|
)
|
|
151
223
|
}
|
|
@@ -25,10 +25,26 @@ const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
|
|
|
25
25
|
return maxLen + 1
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const
|
|
28
|
+
const MatchedCell = ({ text, width, query, align = "left" }: { text: string; width: number; query: string; align?: "left" | "right" }) => {
|
|
29
|
+
const fitted = fitCell(text, width, align)
|
|
30
|
+
const needle = query.trim().toLowerCase()
|
|
31
|
+
const index = needle.length > 0 ? fitted.toLowerCase().indexOf(needle) : -1
|
|
32
|
+
if (index < 0) return <span>{fitted}</span>
|
|
33
|
+
|
|
34
|
+
const end = Math.min(fitted.length, index + needle.length)
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
{index > 0 ? <span>{fitted.slice(0, index)}</span> : null}
|
|
38
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{fitted.slice(index, end)}</span>
|
|
39
|
+
{end < fitted.length ? <span>{fitted.slice(end)}</span> : null}
|
|
40
|
+
</>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const GroupTitle = ({ label, color, filterText }: { label: string; color: string; filterText: string }) => (
|
|
29
45
|
<TextLine>
|
|
30
46
|
<span fg={color}>{GROUP_ICON} </span>
|
|
31
|
-
<span fg={color} attributes={TextAttributes.BOLD}
|
|
47
|
+
<span fg={color} attributes={TextAttributes.BOLD}><MatchedCell text={label} width={label.length} query={filterText} /></span>
|
|
32
48
|
</TextLine>
|
|
33
49
|
)
|
|
34
50
|
|
|
@@ -37,12 +53,14 @@ const PullRequestRow = ({
|
|
|
37
53
|
selected,
|
|
38
54
|
contentWidth,
|
|
39
55
|
numWidth,
|
|
56
|
+
filterText,
|
|
40
57
|
onSelect,
|
|
41
58
|
}: {
|
|
42
59
|
pullRequest: PullRequestItem
|
|
43
60
|
selected: boolean
|
|
44
61
|
contentWidth: number
|
|
45
62
|
numWidth: number
|
|
63
|
+
filterText: string
|
|
46
64
|
onSelect: () => void
|
|
47
65
|
}) => {
|
|
48
66
|
const isClosed = pullRequest.state === "closed"
|
|
@@ -63,9 +81,9 @@ const PullRequestRow = ({
|
|
|
63
81
|
<TextLine fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
|
|
64
82
|
<span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
|
|
65
83
|
<span> </span>
|
|
66
|
-
<span fg={numberColor}
|
|
84
|
+
<span fg={numberColor}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
|
|
67
85
|
<span> </span>
|
|
68
|
-
<span
|
|
86
|
+
<span><MatchedCell text={pullRequest.title} width={titleWidth} query={filterText} /></span>
|
|
69
87
|
<span fg={checkColor}>{fitCell(checkText, checkWidth, "right")}</span>
|
|
70
88
|
<span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
|
|
71
89
|
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
@@ -115,15 +133,16 @@ export const PullRequestList = ({
|
|
|
115
133
|
const numWidth = groupNumberWidth(pullRequests)
|
|
116
134
|
return (
|
|
117
135
|
<box key={repo} flexDirection="column">
|
|
118
|
-
<GroupTitle label={repo} color={repoColor(repo)} />
|
|
136
|
+
<GroupTitle label={repo} color={repoColor(repo)} filterText={filterText} />
|
|
119
137
|
{pullRequests.map((pullRequest) => (
|
|
120
138
|
<PullRequestRow
|
|
121
139
|
key={pullRequest.url}
|
|
122
140
|
pullRequest={pullRequest}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
selected={pullRequest.url === selectedUrl}
|
|
142
|
+
contentWidth={contentWidth}
|
|
143
|
+
numWidth={numWidth}
|
|
144
|
+
filterText={filterText}
|
|
145
|
+
onSelect={() => onSelectPullRequest(pullRequest.url)}
|
|
127
146
|
/>
|
|
128
147
|
))}
|
|
129
148
|
</box>
|
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
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export interface CommentEditorValue {
|
|
2
|
+
readonly body: string
|
|
3
|
+
readonly cursor: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface CommentEditorLine {
|
|
7
|
+
readonly text: string
|
|
8
|
+
readonly start: number
|
|
9
|
+
readonly end: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const clampCursor = (body: string, cursor: number) => Math.max(0, Math.min(cursor, body.length))
|
|
13
|
+
|
|
14
|
+
export const commentEditorLines = (body: string): readonly CommentEditorLine[] => body.split("\n").reduce<CommentEditorLine[]>((ranges, text) => {
|
|
15
|
+
const start = ranges.length === 0 ? 0 : ranges[ranges.length - 1]!.end + 1
|
|
16
|
+
ranges.push({ text, start, end: start + text.length })
|
|
17
|
+
return ranges
|
|
18
|
+
}, [])
|
|
19
|
+
|
|
20
|
+
export const cursorLineIndexForLines = (lines: readonly CommentEditorLine[], cursor: number) =>
|
|
21
|
+
Math.max(0, lines.findIndex((line, index) => cursor <= line.end || index === lines.length - 1))
|
|
22
|
+
|
|
23
|
+
export const cursorLineIndex = (body: string, cursor: number) => {
|
|
24
|
+
const lines = commentEditorLines(body)
|
|
25
|
+
const safeCursor = clampCursor(body, cursor)
|
|
26
|
+
return cursorLineIndexForLines(lines, safeCursor)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const lineStartAt = (body: string, cursor: number) => body.lastIndexOf("\n", Math.max(0, clampCursor(body, cursor) - 1)) + 1
|
|
30
|
+
|
|
31
|
+
export const lineEndAt = (body: string, cursor: number) => {
|
|
32
|
+
const end = body.indexOf("\n", clampCursor(body, cursor))
|
|
33
|
+
return end === -1 ? body.length : end
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const previousWordStart = (body: string, cursor: number) => {
|
|
37
|
+
let index = clampCursor(body, cursor)
|
|
38
|
+
while (index > 0 && /\s/.test(body[index - 1]!)) index--
|
|
39
|
+
while (index > 0 && !/\s/.test(body[index - 1]!)) index--
|
|
40
|
+
return index
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const nextWordEnd = (body: string, cursor: number) => {
|
|
44
|
+
let index = clampCursor(body, cursor)
|
|
45
|
+
while (index < body.length && /\s/.test(body[index]!)) index++
|
|
46
|
+
while (index < body.length && !/\s/.test(body[index]!)) index++
|
|
47
|
+
return index
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const replaceRange = (state: CommentEditorValue, start: number, end: number, text = ""): CommentEditorValue => ({
|
|
51
|
+
body: `${state.body.slice(0, start)}${text}${state.body.slice(end)}`,
|
|
52
|
+
cursor: start + text.length,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const insertText = (state: CommentEditorValue, text: string): CommentEditorValue =>
|
|
56
|
+
replaceRange(state, clampCursor(state.body, state.cursor), clampCursor(state.body, state.cursor), text)
|
|
57
|
+
|
|
58
|
+
export const moveLeft = (state: CommentEditorValue): CommentEditorValue => ({
|
|
59
|
+
...state,
|
|
60
|
+
cursor: Math.max(0, clampCursor(state.body, state.cursor) - 1),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export const moveRight = (state: CommentEditorValue): CommentEditorValue => ({
|
|
64
|
+
...state,
|
|
65
|
+
cursor: Math.min(state.body.length, clampCursor(state.body, state.cursor) + 1),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export const moveLineStart = (state: CommentEditorValue): CommentEditorValue => ({
|
|
69
|
+
...state,
|
|
70
|
+
cursor: lineStartAt(state.body, state.cursor),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export const moveLineEnd = (state: CommentEditorValue): CommentEditorValue => ({
|
|
74
|
+
...state,
|
|
75
|
+
cursor: lineEndAt(state.body, state.cursor),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
export const moveWordBackward = (state: CommentEditorValue): CommentEditorValue => ({
|
|
79
|
+
...state,
|
|
80
|
+
cursor: previousWordStart(state.body, state.cursor),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
export const moveWordForward = (state: CommentEditorValue): CommentEditorValue => ({
|
|
84
|
+
...state,
|
|
85
|
+
cursor: nextWordEnd(state.body, state.cursor),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
export const moveVertically = (state: CommentEditorValue, delta: number): CommentEditorValue => {
|
|
89
|
+
const lines = commentEditorLines(state.body)
|
|
90
|
+
const safeCursor = clampCursor(state.body, state.cursor)
|
|
91
|
+
const currentLineIndex = cursorLineIndexForLines(lines, safeCursor)
|
|
92
|
+
const currentLine = lines[currentLineIndex] ?? { text: state.body, start: 0, end: state.body.length }
|
|
93
|
+
const targetLine = lines[Math.max(0, Math.min(lines.length - 1, currentLineIndex + delta))] ?? currentLine
|
|
94
|
+
const column = safeCursor - currentLine.start
|
|
95
|
+
return { ...state, cursor: Math.min(targetLine.end, targetLine.start + column) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const backspace = (state: CommentEditorValue): CommentEditorValue => {
|
|
99
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
100
|
+
return cursor === 0 ? { ...state, cursor } : replaceRange({ ...state, cursor }, cursor - 1, cursor)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const deleteForward = (state: CommentEditorValue): CommentEditorValue => {
|
|
104
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
105
|
+
return cursor >= state.body.length ? { ...state, cursor } : replaceRange({ ...state, cursor }, cursor, cursor + 1)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const deleteWordBackward = (state: CommentEditorValue): CommentEditorValue => {
|
|
109
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
110
|
+
return replaceRange({ ...state, cursor }, previousWordStart(state.body, cursor), cursor)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const deleteWordForward = (state: CommentEditorValue): CommentEditorValue => {
|
|
114
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
115
|
+
return replaceRange({ ...state, cursor }, cursor, nextWordEnd(state.body, cursor))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const deleteToLineStart = (state: CommentEditorValue): CommentEditorValue => {
|
|
119
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
120
|
+
return replaceRange({ ...state, cursor }, lineStartAt(state.body, cursor), cursor)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const deleteToLineEnd = (state: CommentEditorValue): CommentEditorValue => {
|
|
124
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
125
|
+
return replaceRange({ ...state, cursor }, cursor, lineEndAt(state.body, cursor))
|
|
126
|
+
}
|