@kitlangton/ghui 0.1.21 → 0.2.0
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/package.json +9 -4
- package/src/App.tsx +620 -792
- package/src/appCommands.ts +70 -16
- package/src/domain.ts +13 -0
- package/src/index.tsx +1 -7
- package/src/keyboard/opentuiAdapter.ts +43 -0
- package/src/keymap/all.ts +103 -0
- package/src/keymap/closeModal.ts +13 -0
- package/src/keymap/commandPalette.ts +16 -0
- package/src/keymap/commentModal.ts +73 -0
- package/src/keymap/commentThreadModal.ts +24 -0
- package/src/keymap/detailView.ts +30 -0
- package/src/keymap/diffView.ts +91 -0
- package/src/keymap/filterMode.ts +13 -0
- package/src/keymap/helpers.ts +24 -0
- package/src/keymap/labelModal.ts +16 -0
- package/src/keymap/listNav.ts +119 -0
- package/src/keymap/mergeModal.ts +23 -0
- package/src/keymap/openRepositoryModal.ts +13 -0
- package/src/keymap/themeModal.ts +49 -0
- package/src/mergeActions.ts +4 -1
- package/src/services/GitHubService.ts +42 -6
- package/src/services/MockGitHubService.ts +37 -2
- package/src/themeStore.ts +28 -11
- package/src/ui/CommandPalette.tsx +123 -63
- package/src/ui/DetailsPane.tsx +192 -54
- package/src/ui/FooterHints.tsx +9 -18
- package/src/ui/LoadingLogo.tsx +75 -0
- package/src/ui/PullRequestDiffPane.tsx +25 -18
- package/src/ui/PullRequestList.tsx +6 -2
- package/src/ui/comments.tsx +143 -0
- package/src/ui/diff.ts +198 -6
- package/src/ui/modals.tsx +4 -39
- package/src/ui/primitives.tsx +5 -1
- package/src/ui/singleLineInput.ts +2 -1
- package/src/ui/spinner.ts +1 -0
package/src/ui/FooterHints.tsx
CHANGED
|
@@ -15,7 +15,7 @@ interface HintsContext {
|
|
|
15
15
|
readonly showFilterClear: boolean
|
|
16
16
|
readonly detailFullView: boolean
|
|
17
17
|
readonly diffFullView: boolean
|
|
18
|
-
readonly
|
|
18
|
+
readonly diffRangeActive: boolean
|
|
19
19
|
readonly hasSelection: boolean
|
|
20
20
|
readonly canCloseSelection: boolean
|
|
21
21
|
readonly hasError: boolean
|
|
@@ -33,26 +33,17 @@ const filterEditingHints: readonly HintItem[] = [
|
|
|
33
33
|
{ key: "ctrl-w", label: "word" },
|
|
34
34
|
]
|
|
35
35
|
|
|
36
|
-
const
|
|
37
|
-
{ key: "↑↓", label: "line" },
|
|
38
|
-
{ key: "pgup/pgdn", label: "jump" },
|
|
39
|
-
{ key: "←→", label: "side" },
|
|
40
|
-
{ key: "enter", label: "open" },
|
|
41
|
-
{ key: "a", label: "comment" },
|
|
42
|
-
{ key: "c", label: "done" },
|
|
43
|
-
{ key: "[]", label: "files" },
|
|
44
|
-
{ key: "esc", label: "back" },
|
|
45
|
-
]
|
|
46
|
-
|
|
47
|
-
const diffViewHints: readonly HintItem[] = [
|
|
36
|
+
const diffViewHints = (ctx: HintsContext): readonly HintItem[] => [
|
|
48
37
|
{ key: "esc", label: "back" },
|
|
49
|
-
{ key: "
|
|
50
|
-
{ key: "
|
|
51
|
-
{ key: "
|
|
38
|
+
{ key: "↑↓", label: ctx.diffRangeActive ? "range" : "line" },
|
|
39
|
+
{ key: "enter", label: ctx.diffRangeActive ? "comment" : "open" },
|
|
40
|
+
{ key: "v", label: ctx.diffRangeActive ? "clear" : "range" },
|
|
41
|
+
{ key: "n/p", label: "threads" },
|
|
52
42
|
{ key: "[]", label: "files" },
|
|
43
|
+
{ key: "V", label: "view" },
|
|
44
|
+
{ key: "w", label: "wrap" },
|
|
53
45
|
{ key: "r", label: "reload" },
|
|
54
46
|
{ key: "o", label: "open" },
|
|
55
|
-
{ key: "q", label: "quit" },
|
|
56
47
|
]
|
|
57
48
|
|
|
58
49
|
const detailFullViewHints = (ctx: HintsContext): readonly HintItem[] => [
|
|
@@ -91,7 +82,7 @@ const defaultHints = (ctx: HintsContext): readonly HintItem[] => {
|
|
|
91
82
|
|
|
92
83
|
const footerHints = (ctx: HintsContext): readonly HintItem[] => {
|
|
93
84
|
if (ctx.filterEditing) return filterEditingHints
|
|
94
|
-
if (ctx.diffFullView) return ctx
|
|
85
|
+
if (ctx.diffFullView) return diffViewHints(ctx)
|
|
95
86
|
if (ctx.detailFullView) return detailFullViewHints(ctx)
|
|
96
87
|
return defaultHints(ctx)
|
|
97
88
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import { colors, mixHex } from "./colors.js"
|
|
3
|
+
import type { DetailPlaceholderContent } from "./DetailsPane.js"
|
|
4
|
+
import { centerCell, Filler, PlainLine, TextLine } from "./primitives.js"
|
|
5
|
+
import { SPINNER_FRAMES } from "./spinner.js"
|
|
6
|
+
|
|
7
|
+
type LoadingLogoContent = Pick<DetailPlaceholderContent, "hint">
|
|
8
|
+
|
|
9
|
+
const GHUI_LOGO = ["█▀▀▀ █ █ █ █ ▀█▀", "█ ▀█ █▀▀█ █ █ █ ", "▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀"] as const
|
|
10
|
+
|
|
11
|
+
const LEFT_WORD_WIDTH = 9
|
|
12
|
+
const LOGO_WIDTH = Math.max(...GHUI_LOGO.map((line) => line.length))
|
|
13
|
+
const LOGO_HEIGHT = GHUI_LOGO.length
|
|
14
|
+
const LOGO_BLOCK_HEIGHT = LOGO_HEIGHT + 2
|
|
15
|
+
|
|
16
|
+
const logoColor = (x: number) => (x < LEFT_WORD_WIDTH ? mixHex(colors.accent, colors.text, 0.14) : mixHex(colors.accent, colors.text, 0.52))
|
|
17
|
+
|
|
18
|
+
const LOGO_COLORS = Array.from({ length: LOGO_WIDTH }, (_, index) => logoColor(index))
|
|
19
|
+
const LOGO_ROWS = GHUI_LOGO.map((line) => Array.from(line.padEnd(LOGO_WIDTH, " "), (char, index) => ({ char, color: LOGO_COLORS[index]! })))
|
|
20
|
+
|
|
21
|
+
const LogoRow = ({ row, left }: { row: (typeof LOGO_ROWS)[number]; left: number }) => (
|
|
22
|
+
<TextLine>
|
|
23
|
+
<span fg={colors.muted}>{" ".repeat(left)}</span>
|
|
24
|
+
{row.map(({ char, color }, index) =>
|
|
25
|
+
char === " " ? (
|
|
26
|
+
<span key={index}> </span>
|
|
27
|
+
) : (
|
|
28
|
+
<span key={index} fg={color} attributes={TextAttributes.BOLD}>
|
|
29
|
+
{char}
|
|
30
|
+
</span>
|
|
31
|
+
),
|
|
32
|
+
)}
|
|
33
|
+
</TextLine>
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
export const LoadingLogo = ({ content, width, frame }: { content: LoadingLogoContent; width: number; frame: number }) => {
|
|
37
|
+
const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]!
|
|
38
|
+
const logoLeft = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<box flexDirection="column" width={width}>
|
|
42
|
+
{LOGO_ROWS.map((row, index) => (
|
|
43
|
+
<LogoRow key={index} row={row} left={logoLeft} />
|
|
44
|
+
))}
|
|
45
|
+
<box height={1} />
|
|
46
|
+
<PlainLine text={centerCell(`${spinner} ${content.hint}`, width)} fg={colors.muted} />
|
|
47
|
+
</box>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const LoadingLogoPane = ({ content, width, height, frame }: { content: LoadingLogoContent; width: number; height: number; frame: number }) => {
|
|
52
|
+
if (width < LOGO_WIDTH + 2 || height < LOGO_BLOCK_HEIGHT) {
|
|
53
|
+
const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]!
|
|
54
|
+
const topRows = Math.max(0, Math.floor((height - 1) / 2))
|
|
55
|
+
const bottomRows = Math.max(0, height - topRows - 1)
|
|
56
|
+
return (
|
|
57
|
+
<box height={height} flexDirection="column">
|
|
58
|
+
<Filler rows={topRows} prefix="loading-logo-compact-top" />
|
|
59
|
+
<PlainLine text={centerCell(`${spinner} ${content.hint}`, width)} fg={colors.muted} />
|
|
60
|
+
<Filler rows={bottomRows} prefix="loading-logo-compact-bottom" />
|
|
61
|
+
</box>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const topRows = Math.max(0, Math.floor((height - LOGO_BLOCK_HEIGHT) / 2))
|
|
66
|
+
const bottomRows = Math.max(0, height - topRows - LOGO_BLOCK_HEIGHT)
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<box height={height} flexDirection="column">
|
|
70
|
+
<Filler rows={topRows} prefix="loading-logo-top" />
|
|
71
|
+
<LoadingLogo content={content} width={width} frame={frame} />
|
|
72
|
+
<Filler rows={bottomRows} prefix="loading-logo-bottom" />
|
|
73
|
+
</box>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -2,7 +2,8 @@ import type { DiffRenderable, MouseEvent, ScrollBoxRenderable } from "@opentui/c
|
|
|
2
2
|
import { useMemo, type Ref } from "react"
|
|
3
3
|
import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
|
|
4
4
|
import { colors, type ThemeId } from "./colors.js"
|
|
5
|
-
import {
|
|
5
|
+
import { CommentBodyLine, commentCountText, commentMetaSegments, CommentSegmentsLine } from "./comments.js"
|
|
6
|
+
import { createDiffSyntaxStyle, diffCommentAnchorLabel, diffCommentLineLabel, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileIndexAtLine, type DiffFileStats, type DiffView, type DiffWhitespaceMode, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
|
|
6
7
|
import { LoadingPane, StatusCard } from "./DetailsPane.js"
|
|
7
8
|
import { DiffStats } from "./diffStats.js"
|
|
8
9
|
import { Divider, fitCell, PaddedRow, PlainLine, TextLine } from "./primitives.js"
|
|
@@ -71,14 +72,15 @@ export const PullRequestDiffPane = ({
|
|
|
71
72
|
stackedFiles,
|
|
72
73
|
scrollTop,
|
|
73
74
|
view,
|
|
75
|
+
whitespaceMode,
|
|
74
76
|
wrapMode,
|
|
75
77
|
paneWidth,
|
|
76
78
|
height,
|
|
77
79
|
loadingIndicator,
|
|
78
80
|
scrollRef,
|
|
79
81
|
setDiffRef,
|
|
80
|
-
commentMode,
|
|
81
82
|
selectedCommentAnchor,
|
|
83
|
+
selectedCommentLabel,
|
|
82
84
|
selectedCommentThread,
|
|
83
85
|
onSelectCommentLine,
|
|
84
86
|
themeId,
|
|
@@ -88,14 +90,15 @@ export const PullRequestDiffPane = ({
|
|
|
88
90
|
stackedFiles: readonly StackedDiffFilePatch[]
|
|
89
91
|
scrollTop: number
|
|
90
92
|
view: DiffView
|
|
93
|
+
whitespaceMode: DiffWhitespaceMode
|
|
91
94
|
wrapMode: DiffWrapMode
|
|
92
95
|
paneWidth: number
|
|
93
96
|
height: number
|
|
94
97
|
loadingIndicator: string
|
|
95
98
|
scrollRef: Ref<ScrollBoxRenderable>
|
|
96
99
|
setDiffRef: (index: number, diff: DiffRenderable | null) => void
|
|
97
|
-
commentMode: boolean
|
|
98
100
|
selectedCommentAnchor: StackedDiffCommentAnchor | null
|
|
101
|
+
selectedCommentLabel: string | null
|
|
99
102
|
selectedCommentThread: readonly PullRequestReviewComment[]
|
|
100
103
|
onSelectCommentLine: (renderLine: number, side: DiffCommentSide | null) => void
|
|
101
104
|
themeId: ThemeId
|
|
@@ -130,29 +133,33 @@ export const PullRequestDiffPane = ({
|
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
if (readyFiles.length === 0 || stackedFiles.length === 0) {
|
|
133
|
-
return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
|
|
136
|
+
return <LoadingPane content={{ title: whitespaceMode === "ignore" ? "No non-whitespace diff" : "No diff", hint: whitespaceMode === "ignore" ? "Use the command palette to show whitespace changes" : "This PR has no patch contents" }} width={paneWidth} height={height} />
|
|
134
137
|
}
|
|
135
138
|
|
|
136
|
-
const
|
|
137
|
-
const commentPeek =
|
|
139
|
+
const hasSelectedCommentAnchor = selectedCommentAnchor !== null
|
|
140
|
+
const commentPeek = hasSelectedCommentAnchor && selectedCommentThread.length > 0
|
|
138
141
|
? selectedCommentThread[selectedCommentThread.length - 1]!
|
|
139
142
|
: null
|
|
140
|
-
const commentPeekCount = selectedCommentThread.length === 1 ? "1 comment" : `${selectedCommentThread.length} comments`
|
|
141
|
-
const commentPeekBody = commentPeek?.body.split("\n")[0]?.trim() || "(empty comment)"
|
|
142
143
|
const commentPeekMeta = commentPeek && selectedCommentAnchor
|
|
143
|
-
?
|
|
144
|
-
|
|
144
|
+
? commentMetaSegments({
|
|
145
|
+
item: commentPeek,
|
|
146
|
+
markerLabel: diffCommentLineLabel(selectedCommentAnchor),
|
|
147
|
+
groups: [
|
|
148
|
+
[{ text: commentCountText(selectedCommentThread.length), fg: colors.muted }],
|
|
149
|
+
[{ text: "enter", fg: colors.text }, { text: " thread", fg: colors.muted }],
|
|
150
|
+
],
|
|
151
|
+
})
|
|
152
|
+
: []
|
|
145
153
|
const stickyScrollTop = Math.max(0, Math.floor(scrollTop))
|
|
146
|
-
const
|
|
147
|
-
const
|
|
154
|
+
const stickyArrayIndex = stackedDiffFileIndexAtLine(stackedFiles, stickyScrollTop)
|
|
155
|
+
const stickyFile = stickyArrayIndex >= 0 ? stackedFiles[stickyArrayIndex] : stackedFiles[0]
|
|
148
156
|
const incomingStickyFile = stickyArrayIndex >= 0 ? stackedFiles[stickyArrayIndex + 1] : undefined
|
|
149
157
|
const incomingHeaderDistance = incomingStickyFile ? incomingStickyFile.headerLine - stickyScrollTop : Number.POSITIVE_INFINITY
|
|
150
158
|
const incomingFile = incomingHeaderDistance === 1 ? incomingStickyFile : undefined
|
|
151
159
|
const stickyCommentLabelFor = (stackedFile: StackedDiffFilePatch | undefined) => {
|
|
152
|
-
if (!
|
|
153
|
-
if (!selectedCommentAnchor) return " c no lines"
|
|
160
|
+
if (!selectedCommentAnchor) return " no lines"
|
|
154
161
|
if (selectedCommentAnchor.fileIndex !== stackedFile?.index) return ""
|
|
155
|
-
return ` ${
|
|
162
|
+
return ` ${selectedCommentLabel ?? diffCommentAnchorLabel(selectedCommentAnchor)}`
|
|
156
163
|
}
|
|
157
164
|
const stickyCommentColor = selectedCommentAnchor?.side === "LEFT" ? colors.status.failing : colors.status.passing
|
|
158
165
|
const handleDiffMouseDown = function (this: ScrollBoxRenderable, event: MouseEvent) {
|
|
@@ -172,7 +179,7 @@ export const PullRequestDiffPane = ({
|
|
|
172
179
|
<box height={height} flexDirection="column">
|
|
173
180
|
<DiffPaneHeader pullRequest={pullRequest} paneWidth={paneWidth} />
|
|
174
181
|
<Divider width={paneWidth} />
|
|
175
|
-
<scrollbox ref={scrollRef}
|
|
182
|
+
<scrollbox ref={scrollRef} focusable={false} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
|
|
176
183
|
{stackedFiles.map((stackedFile) => (
|
|
177
184
|
<box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
|
|
178
185
|
{stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
|
|
@@ -229,10 +236,10 @@ export const PullRequestDiffPane = ({
|
|
|
229
236
|
<>
|
|
230
237
|
<Divider width={paneWidth} />
|
|
231
238
|
<PaddedRow>
|
|
232
|
-
<
|
|
239
|
+
<CommentSegmentsLine segments={commentPeekMeta} />
|
|
233
240
|
</PaddedRow>
|
|
234
241
|
<PaddedRow>
|
|
235
|
-
<
|
|
242
|
+
<CommentBodyLine body={commentPeek.body} width={Math.max(1, paneWidth - 2)} />
|
|
236
243
|
</PaddedRow>
|
|
237
244
|
</>
|
|
238
245
|
) : null}
|
|
@@ -69,6 +69,7 @@ export const buildPullRequestListRows = ({
|
|
|
69
69
|
loadedCount,
|
|
70
70
|
hasMore,
|
|
71
71
|
isLoadingMore,
|
|
72
|
+
loadingIndicator = "-",
|
|
72
73
|
}: {
|
|
73
74
|
readonly groups: PullRequestGroups
|
|
74
75
|
readonly status: LoadStatus
|
|
@@ -78,6 +79,7 @@ export const buildPullRequestListRows = ({
|
|
|
78
79
|
readonly loadedCount: number
|
|
79
80
|
readonly hasMore: boolean
|
|
80
81
|
readonly isLoadingMore: boolean
|
|
82
|
+
readonly loadingIndicator?: string
|
|
81
83
|
}): readonly PullRequestListRow[] => {
|
|
82
84
|
const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
|
|
83
85
|
const rows: PullRequestListRow[] = [{ _tag: "title" }]
|
|
@@ -90,7 +92,7 @@ export const buildPullRequestListRows = ({
|
|
|
90
92
|
for (const pullRequest of pullRequests) rows.push({ _tag: "pull-request", pullRequest, groupPullRequests: pullRequests })
|
|
91
93
|
}
|
|
92
94
|
if (status === "ready" && itemCount > 0 && (hasMore || isLoadingMore)) {
|
|
93
|
-
rows.push({ _tag: "load-more", text: isLoadingMore ?
|
|
95
|
+
rows.push({ _tag: "load-more", text: isLoadingMore ? `${loadingIndicator} Loading more pull requests... (${loadedCount} loaded)` : `- ${loadedCount} loaded, more available` })
|
|
94
96
|
}
|
|
95
97
|
return rows
|
|
96
98
|
}
|
|
@@ -152,6 +154,7 @@ export const PullRequestList = ({
|
|
|
152
154
|
loadedCount,
|
|
153
155
|
hasMore,
|
|
154
156
|
isLoadingMore,
|
|
157
|
+
loadingIndicator,
|
|
155
158
|
onSelectPullRequest,
|
|
156
159
|
}: {
|
|
157
160
|
groups: PullRequestGroups
|
|
@@ -165,9 +168,10 @@ export const PullRequestList = ({
|
|
|
165
168
|
loadedCount: number
|
|
166
169
|
hasMore: boolean
|
|
167
170
|
isLoadingMore: boolean
|
|
171
|
+
loadingIndicator: string
|
|
168
172
|
onSelectPullRequest: (url: string) => void
|
|
169
173
|
}) => {
|
|
170
|
-
const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, loadedCount, hasMore, isLoadingMore })
|
|
174
|
+
const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, loadedCount, hasMore, isLoadingMore, loadingIndicator })
|
|
171
175
|
|
|
172
176
|
return (
|
|
173
177
|
<box width={contentWidth} flexDirection="column">
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import { formatRelativeDate } from "../date.js"
|
|
3
|
+
import type { DiffCommentSide } from "../domain.js"
|
|
4
|
+
import { colors } from "./colors.js"
|
|
5
|
+
import { fitCell, TextLine } from "./primitives.js"
|
|
6
|
+
|
|
7
|
+
export interface CommentSegment {
|
|
8
|
+
readonly text: string
|
|
9
|
+
readonly fg: string
|
|
10
|
+
readonly bold?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CommentDisplayLine {
|
|
14
|
+
readonly key: string
|
|
15
|
+
readonly segments: readonly CommentSegment[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CommentDisplayItem {
|
|
19
|
+
readonly id: string
|
|
20
|
+
readonly author: string
|
|
21
|
+
readonly body: string
|
|
22
|
+
readonly createdAt: Date | null
|
|
23
|
+
readonly side?: DiffCommentSide | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const commentCountText = (count: number) => count === 1 ? "1 comment" : `${count} comments`
|
|
27
|
+
|
|
28
|
+
export const commentSideColor = (side: DiffCommentSide | null | undefined) =>
|
|
29
|
+
side === "LEFT" ? colors.status.failing : side === "RIGHT" ? colors.status.passing : colors.count
|
|
30
|
+
|
|
31
|
+
const commentTimestamp = (date: Date | null) => {
|
|
32
|
+
if (!date) return ""
|
|
33
|
+
const ageMs = Date.now() - date.getTime()
|
|
34
|
+
const minuteMs = 60_000
|
|
35
|
+
const hourMs = 60 * minuteMs
|
|
36
|
+
if (ageMs < minuteMs) return "just now"
|
|
37
|
+
if (ageMs < hourMs) return `${Math.max(1, Math.floor(ageMs / minuteMs))}m ago`
|
|
38
|
+
if (ageMs < 24 * hourMs) return `${Math.max(1, Math.floor(ageMs / hourMs))}h ago`
|
|
39
|
+
return formatRelativeDate(date)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const inlineCommentSegments = (text: string, fg = colors.text): readonly CommentSegment[] =>
|
|
43
|
+
text.split(/(`[^`]+`)/g).filter((part) => part.length > 0).map((part) =>
|
|
44
|
+
part.startsWith("`") && part.endsWith("`")
|
|
45
|
+
? { text: part.slice(1, -1), fg: colors.inlineCode }
|
|
46
|
+
: { text: part, fg },
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const wrapCommentText = (body: string, width: number) => {
|
|
50
|
+
const safeWidth = Math.max(1, width)
|
|
51
|
+
const lines = body.trim().length === 0 ? ["(empty comment)"] : body.replace(/\r/g, "").trim().split("\n")
|
|
52
|
+
return lines.flatMap((line) => {
|
|
53
|
+
const trimmed = line.trim()
|
|
54
|
+
if (trimmed.length === 0) return []
|
|
55
|
+
const wrapped: string[] = []
|
|
56
|
+
for (let index = 0; index < trimmed.length; index += safeWidth) {
|
|
57
|
+
wrapped.push(trimmed.slice(index, index + safeWidth))
|
|
58
|
+
}
|
|
59
|
+
return wrapped
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const appendMetaGroup = (segments: CommentSegment[], group: readonly CommentSegment[]) => {
|
|
64
|
+
if (group.length === 0) return
|
|
65
|
+
segments.push({ text: " · ", fg: colors.muted }, ...group)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const commentMetaSegments = ({
|
|
69
|
+
item,
|
|
70
|
+
markerLabel,
|
|
71
|
+
groups = [],
|
|
72
|
+
}: {
|
|
73
|
+
readonly item: CommentDisplayItem
|
|
74
|
+
readonly markerLabel?: string | null | undefined
|
|
75
|
+
readonly groups?: readonly (readonly CommentSegment[])[] | undefined
|
|
76
|
+
}): readonly CommentSegment[] => {
|
|
77
|
+
const sideColor = commentSideColor(item.side)
|
|
78
|
+
const timestamp = commentTimestamp(item.createdAt)
|
|
79
|
+
const segments: CommentSegment[] = [
|
|
80
|
+
{ text: "•", fg: colors.count, bold: true },
|
|
81
|
+
...(markerLabel ? [{ text: ` ${markerLabel}`, fg: sideColor, bold: true }] : []),
|
|
82
|
+
{ text: " ", fg: colors.muted },
|
|
83
|
+
{ text: item.author, fg: colors.count, bold: true },
|
|
84
|
+
]
|
|
85
|
+
if (timestamp) appendMetaGroup(segments, [{ text: timestamp, fg: colors.muted }])
|
|
86
|
+
for (const group of groups) appendMetaGroup(segments, group)
|
|
87
|
+
return segments
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const commentBodyRows = ({
|
|
91
|
+
keyPrefix,
|
|
92
|
+
body,
|
|
93
|
+
width,
|
|
94
|
+
}: {
|
|
95
|
+
readonly keyPrefix: string
|
|
96
|
+
readonly body: string
|
|
97
|
+
readonly width: number
|
|
98
|
+
}): readonly CommentDisplayLine[] =>
|
|
99
|
+
wrapCommentText(body, Math.max(1, width - 2)).map((line, index) => ({
|
|
100
|
+
key: `${keyPrefix}:body:${index}`,
|
|
101
|
+
segments: [
|
|
102
|
+
{ text: "│ ", fg: colors.muted },
|
|
103
|
+
...inlineCommentSegments(line),
|
|
104
|
+
],
|
|
105
|
+
}))
|
|
106
|
+
|
|
107
|
+
export const commentDisplayRows = ({
|
|
108
|
+
item,
|
|
109
|
+
width,
|
|
110
|
+
markerLabel,
|
|
111
|
+
groups,
|
|
112
|
+
}: {
|
|
113
|
+
readonly item: CommentDisplayItem
|
|
114
|
+
readonly width: number
|
|
115
|
+
readonly markerLabel?: string | null | undefined
|
|
116
|
+
readonly groups?: readonly (readonly CommentSegment[])[] | undefined
|
|
117
|
+
}): readonly CommentDisplayLine[] => [
|
|
118
|
+
{ key: `${item.id}:meta`, segments: commentMetaSegments({ item, markerLabel, groups }) },
|
|
119
|
+
...commentBodyRows({ keyPrefix: item.id, body: item.body, width }),
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
export const firstCommentBodyLine = (body: string) => {
|
|
123
|
+
const text = body.trim().length > 0 ? body : "(empty comment)"
|
|
124
|
+
const newlineIndex = text.indexOf("\n")
|
|
125
|
+
return (newlineIndex >= 0 ? text.slice(0, newlineIndex) : text).trim() || "(empty comment)"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const CommentSegmentsLine = ({ segments }: { segments: readonly CommentSegment[] }) => (
|
|
129
|
+
<TextLine>
|
|
130
|
+
{segments.map((segment, index) => segment.bold ? (
|
|
131
|
+
<span key={index} fg={segment.fg} attributes={TextAttributes.BOLD}>{segment.text}</span>
|
|
132
|
+
) : (
|
|
133
|
+
<span key={index} fg={segment.fg}>{segment.text}</span>
|
|
134
|
+
))}
|
|
135
|
+
</TextLine>
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
export const CommentBodyLine = ({ body, width }: { body: string; width: number }) => (
|
|
139
|
+
<CommentSegmentsLine segments={[
|
|
140
|
+
{ text: "│ ", fg: colors.muted },
|
|
141
|
+
{ text: fitCell(firstCommentBodyLine(body), Math.max(1, width - 2)), fg: colors.text },
|
|
142
|
+
]} />
|
|
143
|
+
)
|
package/src/ui/diff.ts
CHANGED
|
@@ -9,6 +9,9 @@ export type DiffView = Schema.Schema.Type<typeof DiffView>
|
|
|
9
9
|
export const DiffWrapMode = Schema.Literals(["none", "word"])
|
|
10
10
|
export type DiffWrapMode = Schema.Schema.Type<typeof DiffWrapMode>
|
|
11
11
|
|
|
12
|
+
export const DiffWhitespaceMode = Schema.Literals(["ignore", "show"])
|
|
13
|
+
export type DiffWhitespaceMode = Schema.Schema.Type<typeof DiffWhitespaceMode>
|
|
14
|
+
|
|
12
15
|
export const DiffCommentKind = Schema.Literals(["addition", "deletion", "context"])
|
|
13
16
|
export type DiffCommentKind = Schema.Schema.Type<typeof DiffCommentKind>
|
|
14
17
|
|
|
@@ -154,6 +157,153 @@ const normalizeHunkLineCounts = (patch: string) => {
|
|
|
154
157
|
return normalized.join("\n")
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
const whitespaceComparableText = (text: string) => text.replace(/\s+/g, "")
|
|
161
|
+
const MAX_WHITESPACE_LCS_CELLS = 40_000
|
|
162
|
+
|
|
163
|
+
const linearWhitespaceEquivalentMatches = (deletions: readonly string[], additions: readonly string[]) => {
|
|
164
|
+
const additionsByKey = new Map<string, number[]>()
|
|
165
|
+
const cursors = new Map<string, number>()
|
|
166
|
+
for (let index = 0; index < additions.length; index++) {
|
|
167
|
+
const key = whitespaceComparableText(additions[index]!.slice(1))
|
|
168
|
+
let bucket = additionsByKey.get(key)
|
|
169
|
+
if (!bucket) {
|
|
170
|
+
bucket = []
|
|
171
|
+
additionsByKey.set(key, bucket)
|
|
172
|
+
}
|
|
173
|
+
bucket.push(index)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const matches: Array<{ readonly oldIndex: number; readonly newIndex: number }> = []
|
|
177
|
+
let minimumNewIndex = 0
|
|
178
|
+
for (let oldIndex = 0; oldIndex < deletions.length; oldIndex++) {
|
|
179
|
+
const key = whitespaceComparableText(deletions[oldIndex]!.slice(1))
|
|
180
|
+
const candidates = additionsByKey.get(key)
|
|
181
|
+
if (!candidates) continue
|
|
182
|
+
let cursor = cursors.get(key) ?? 0
|
|
183
|
+
while (cursor < candidates.length && candidates[cursor]! < minimumNewIndex) cursor++
|
|
184
|
+
const newIndex = candidates[cursor]
|
|
185
|
+
if (newIndex === undefined) continue
|
|
186
|
+
matches.push({ oldIndex, newIndex })
|
|
187
|
+
cursors.set(key, cursor + 1)
|
|
188
|
+
minimumNewIndex = newIndex + 1
|
|
189
|
+
}
|
|
190
|
+
return matches
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const whitespaceEquivalentMatches = (deletions: readonly string[], additions: readonly string[]) => {
|
|
194
|
+
if ((deletions.length + 1) * (additions.length + 1) > MAX_WHITESPACE_LCS_CELLS) {
|
|
195
|
+
return linearWhitespaceEquivalentMatches(deletions, additions)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const oldKeys = deletions.map((line) => whitespaceComparableText(line.slice(1)))
|
|
199
|
+
const newKeys = additions.map((line) => whitespaceComparableText(line.slice(1)))
|
|
200
|
+
const lengths = Array.from({ length: oldKeys.length + 1 }, () => Array<number>(newKeys.length + 1).fill(0))
|
|
201
|
+
|
|
202
|
+
for (let oldIndex = oldKeys.length - 1; oldIndex >= 0; oldIndex--) {
|
|
203
|
+
for (let newIndex = newKeys.length - 1; newIndex >= 0; newIndex--) {
|
|
204
|
+
lengths[oldIndex]![newIndex] = oldKeys[oldIndex] === newKeys[newIndex]
|
|
205
|
+
? lengths[oldIndex + 1]![newIndex + 1]! + 1
|
|
206
|
+
: Math.max(lengths[oldIndex + 1]![newIndex]!, lengths[oldIndex]![newIndex + 1]!)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const matches: Array<{ readonly oldIndex: number; readonly newIndex: number }> = []
|
|
211
|
+
let oldIndex = 0
|
|
212
|
+
let newIndex = 0
|
|
213
|
+
while (oldIndex < oldKeys.length && newIndex < newKeys.length) {
|
|
214
|
+
if (oldKeys[oldIndex] === newKeys[newIndex]) {
|
|
215
|
+
matches.push({ oldIndex, newIndex })
|
|
216
|
+
oldIndex++
|
|
217
|
+
newIndex++
|
|
218
|
+
} else if (lengths[oldIndex + 1]![newIndex]! >= lengths[oldIndex]![newIndex + 1]!) {
|
|
219
|
+
oldIndex++
|
|
220
|
+
} else {
|
|
221
|
+
newIndex++
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return matches
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const mergeWhitespaceEquivalentChanges = (deletions: readonly string[], additions: readonly string[]) => {
|
|
229
|
+
const matches = whitespaceEquivalentMatches(deletions, additions)
|
|
230
|
+
const merged: string[] = []
|
|
231
|
+
let oldCursor = 0
|
|
232
|
+
let newCursor = 0
|
|
233
|
+
|
|
234
|
+
for (const match of matches) {
|
|
235
|
+
while (oldCursor < match.oldIndex) merged.push(deletions[oldCursor++]!)
|
|
236
|
+
while (newCursor < match.newIndex) merged.push(additions[newCursor++]!)
|
|
237
|
+
merged.push(` ${additions[match.newIndex]!.slice(1)}`)
|
|
238
|
+
oldCursor = match.oldIndex + 1
|
|
239
|
+
newCursor = match.newIndex + 1
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
while (oldCursor < deletions.length) merged.push(deletions[oldCursor++]!)
|
|
243
|
+
while (newCursor < additions.length) merged.push(additions[newCursor++]!)
|
|
244
|
+
return merged
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const minimizeWhitespaceHunk = (header: string, body: readonly string[]) => {
|
|
248
|
+
const minimized: string[] = []
|
|
249
|
+
let deletions: string[] = []
|
|
250
|
+
let additions: string[] = []
|
|
251
|
+
|
|
252
|
+
const flushChangeBlock = () => {
|
|
253
|
+
if (deletions.length === 0 && additions.length === 0) return
|
|
254
|
+
minimized.push(...mergeWhitespaceEquivalentChanges(deletions, additions))
|
|
255
|
+
deletions = []
|
|
256
|
+
additions = []
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const line of body) {
|
|
260
|
+
const firstChar = line[0]
|
|
261
|
+
if (firstChar === "-") {
|
|
262
|
+
deletions.push(line)
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (firstChar === "+") {
|
|
267
|
+
additions.push(line)
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
flushChangeBlock()
|
|
272
|
+
minimized.push(line)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
flushChangeBlock()
|
|
276
|
+
return minimized.some((line) => line[0] === "-" || line[0] === "+") ? [header, ...minimized] : []
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export const minimizeWhitespacePatch = (patch: string) => {
|
|
280
|
+
const lines = patch.split("\n")
|
|
281
|
+
const minimized: string[] = []
|
|
282
|
+
|
|
283
|
+
for (let index = 0; index < lines.length;) {
|
|
284
|
+
const line = lines[index]!
|
|
285
|
+
if (!line.match(hunkHeaderPattern)) {
|
|
286
|
+
minimized.push(line)
|
|
287
|
+
index++
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let end = index + 1
|
|
292
|
+
while (end < lines.length && !lines[end]!.match(hunkHeaderPattern) && !lines[end]!.startsWith("diff --git ")) end++
|
|
293
|
+
minimized.push(...minimizeWhitespaceHunk(line, lines.slice(index + 1, end)))
|
|
294
|
+
index = end
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return normalizeHunkLineCounts(minimized.join("\n")).trimEnd()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export const minimizeWhitespaceDiffFiles = (files: readonly DiffFilePatch[]): readonly DiffFilePatch[] =>
|
|
301
|
+
files.flatMap((file) => {
|
|
302
|
+
const patch = minimizeWhitespacePatch(file.patch)
|
|
303
|
+
if (file.patch.split("\n").some((line) => line.match(hunkHeaderPattern)) && !patch.split("\n").some((line) => line.match(hunkHeaderPattern))) return []
|
|
304
|
+
return [{ ...file, patch }]
|
|
305
|
+
})
|
|
306
|
+
|
|
157
307
|
export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
|
|
158
308
|
const trimmed = patch.trimEnd()
|
|
159
309
|
if (trimmed.length === 0) return []
|
|
@@ -200,8 +350,24 @@ export const buildStackedDiffFiles = (
|
|
|
200
350
|
})
|
|
201
351
|
}
|
|
202
352
|
|
|
353
|
+
export const stackedDiffFileIndexAtLine = (stackedFiles: readonly StackedDiffFilePatch[], line: number) => {
|
|
354
|
+
let low = 0
|
|
355
|
+
let high = stackedFiles.length - 1
|
|
356
|
+
let match = -1
|
|
357
|
+
while (low <= high) {
|
|
358
|
+
const mid = (low + high) >>> 1
|
|
359
|
+
if (stackedFiles[mid]!.headerLine <= line) {
|
|
360
|
+
match = mid
|
|
361
|
+
low = mid + 1
|
|
362
|
+
} else {
|
|
363
|
+
high = mid - 1
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return match
|
|
367
|
+
}
|
|
368
|
+
|
|
203
369
|
export const stackedDiffFileAtLine = (stackedFiles: readonly StackedDiffFilePatch[], line: number) =>
|
|
204
|
-
stackedFiles
|
|
370
|
+
stackedFiles[stackedDiffFileIndexAtLine(stackedFiles, line)]
|
|
205
371
|
|
|
206
372
|
export const diffStatText = (pullRequest: PullRequestItem) => {
|
|
207
373
|
if (!pullRequest.detailLoaded) return "loading details"
|
|
@@ -212,7 +378,11 @@ export const diffStatText = (pullRequest: PullRequestItem) => {
|
|
|
212
378
|
|
|
213
379
|
export const diffCommentLocationKey = (location: Pick<PullRequestReviewComment, "path" | "side" | "line">) => `${location.path}:${location.side}:${location.line}`
|
|
214
380
|
|
|
215
|
-
export const
|
|
381
|
+
export const diffCommentSideLabel = (anchor: Pick<DiffCommentAnchor, "side">) => anchor.side === "RIGHT" ? "→" : "←"
|
|
382
|
+
|
|
383
|
+
export const diffCommentLineLabel = (anchor: Pick<DiffCommentAnchor, "side" | "line">) => `${anchor.side === "RIGHT" ? "+" : "-"}${anchor.line}`
|
|
384
|
+
|
|
385
|
+
export const diffCommentAnchorLabel = (anchor: Pick<DiffCommentAnchor, "side" | "line">) => `${diffCommentSideLabel(anchor)} ${diffCommentLineLabel(anchor)}`
|
|
216
386
|
|
|
217
387
|
type PendingDiffCommentAnchor = Omit<DiffCommentAnchor, "renderLine">
|
|
218
388
|
|
|
@@ -244,10 +414,6 @@ export const diffFileStats = (file: DiffFilePatch): DiffFileStats => {
|
|
|
244
414
|
return { additions, deletions }
|
|
245
415
|
}
|
|
246
416
|
|
|
247
|
-
export const diffFileStatText = (file: DiffFilePatch) => {
|
|
248
|
-
return diffFileStatsText(diffFileStats(file))
|
|
249
|
-
}
|
|
250
|
-
|
|
251
417
|
export const diffFileStatsText = (stats: DiffFileStats) => {
|
|
252
418
|
return [
|
|
253
419
|
stats.additions > 0 ? `+${stats.additions}` : null,
|
|
@@ -347,6 +513,32 @@ export const getStackedDiffCommentAnchors = (
|
|
|
347
513
|
renderLine: stackedFile.diffStartLine + anchor.renderLine,
|
|
348
514
|
})))
|
|
349
515
|
|
|
516
|
+
export const verticalDiffAnchor = <Anchor extends Pick<DiffCommentAnchor, "renderLine" | "side">>(
|
|
517
|
+
anchors: readonly Anchor[],
|
|
518
|
+
currentAnchor: Anchor | null,
|
|
519
|
+
delta: number,
|
|
520
|
+
preferredSide: DiffCommentSide | null = null,
|
|
521
|
+
) => {
|
|
522
|
+
if (anchors.length === 0) return null
|
|
523
|
+
const rows = [...new Set(anchors.map((anchor) => anchor.renderLine))].sort((left, right) => left - right)
|
|
524
|
+
const current = currentAnchor && anchors.includes(currentAnchor) ? currentAnchor : anchors[0]!
|
|
525
|
+
const currentRowIndex = Math.max(0, rows.indexOf(current.renderLine))
|
|
526
|
+
const nextRow = rows[Math.max(0, Math.min(rows.length - 1, currentRowIndex + delta))]
|
|
527
|
+
if (nextRow === undefined) return null
|
|
528
|
+
const targetSide = preferredSide ?? current.side
|
|
529
|
+
return anchors.find((anchor) => anchor.renderLine === nextRow && anchor.side === targetSide)
|
|
530
|
+
?? anchors.find((anchor) => anchor.renderLine === nextRow)
|
|
531
|
+
?? null
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export const diffAnchorOnSide = <Anchor extends Pick<DiffCommentAnchor, "renderLine" | "side">>(
|
|
535
|
+
anchors: readonly Anchor[],
|
|
536
|
+
currentAnchor: Anchor | null,
|
|
537
|
+
side: DiffCommentSide,
|
|
538
|
+
) => currentAnchor
|
|
539
|
+
? anchors.find((anchor) => anchor.renderLine === currentAnchor.renderLine && anchor.side === side) ?? null
|
|
540
|
+
: null
|
|
541
|
+
|
|
350
542
|
export const nearestDiffCommentAnchorIndex = (anchors: readonly DiffCommentAnchor[], renderLine: number) => {
|
|
351
543
|
if (anchors.length === 0) return 0
|
|
352
544
|
const nextIndex = anchors.findIndex((anchor) => anchor.renderLine >= renderLine)
|