@kitlangton/ghui 0.1.22 → 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 +5 -4
- package/src/App.tsx +338 -353
- package/src/appCommands.ts +16 -4
- package/src/domain.ts +11 -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 +41 -6
- package/src/services/MockGitHubService.ts +37 -2
- package/src/themeStore.ts +28 -11
- package/src/ui/CommandPalette.tsx +115 -65
- package/src/ui/DetailsPane.tsx +144 -32
- package/src/ui/LoadingLogo.tsx +75 -0
- package/src/ui/PullRequestDiffPane.tsx +19 -17
- package/src/ui/comments.tsx +143 -0
- package/src/ui/diff.ts +194 -8
- 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/keyboard/createKeymap.ts +0 -25
- package/src/keyboard/useAppCommandRegistry.ts +0 -30
- package/src/keyboard/useScopedBindings.ts +0 -66
|
@@ -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"
|
|
@@ -65,17 +66,13 @@ const FileHeader = ({
|
|
|
65
66
|
)
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
const firstCommentBodyLine = (body: string) => {
|
|
69
|
-
const newlineIndex = body.indexOf("\n")
|
|
70
|
-
return (newlineIndex >= 0 ? body.slice(0, newlineIndex) : body).trim() || "(empty comment)"
|
|
71
|
-
}
|
|
72
|
-
|
|
73
69
|
export const PullRequestDiffPane = ({
|
|
74
70
|
pullRequest,
|
|
75
71
|
diffState,
|
|
76
72
|
stackedFiles,
|
|
77
73
|
scrollTop,
|
|
78
74
|
view,
|
|
75
|
+
whitespaceMode,
|
|
79
76
|
wrapMode,
|
|
80
77
|
paneWidth,
|
|
81
78
|
height,
|
|
@@ -93,6 +90,7 @@ export const PullRequestDiffPane = ({
|
|
|
93
90
|
stackedFiles: readonly StackedDiffFilePatch[]
|
|
94
91
|
scrollTop: number
|
|
95
92
|
view: DiffView
|
|
93
|
+
whitespaceMode: DiffWhitespaceMode
|
|
96
94
|
wrapMode: DiffWrapMode
|
|
97
95
|
paneWidth: number
|
|
98
96
|
height: number
|
|
@@ -135,22 +133,26 @@ export const PullRequestDiffPane = ({
|
|
|
135
133
|
}
|
|
136
134
|
|
|
137
135
|
if (readyFiles.length === 0 || stackedFiles.length === 0) {
|
|
138
|
-
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} />
|
|
139
137
|
}
|
|
140
138
|
|
|
141
|
-
const selectedSideLabel = selectedCommentAnchor?.side === "RIGHT" ? "right" : selectedCommentAnchor?.side === "LEFT" ? "left" : null
|
|
142
139
|
const hasSelectedCommentAnchor = selectedCommentAnchor !== null
|
|
143
140
|
const commentPeek = hasSelectedCommentAnchor && selectedCommentThread.length > 0
|
|
144
141
|
? selectedCommentThread[selectedCommentThread.length - 1]!
|
|
145
142
|
: null
|
|
146
|
-
const commentPeekCount = selectedCommentThread.length === 1 ? "1 comment" : `${selectedCommentThread.length} comments`
|
|
147
|
-
const commentPeekBody = commentPeek ? firstCommentBodyLine(commentPeek.body) : "(empty comment)"
|
|
148
143
|
const commentPeekMeta = commentPeek && selectedCommentAnchor
|
|
149
|
-
?
|
|
150
|
-
|
|
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
|
+
: []
|
|
151
153
|
const stickyScrollTop = Math.max(0, Math.floor(scrollTop))
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
+
const stickyArrayIndex = stackedDiffFileIndexAtLine(stackedFiles, stickyScrollTop)
|
|
155
|
+
const stickyFile = stickyArrayIndex >= 0 ? stackedFiles[stickyArrayIndex] : stackedFiles[0]
|
|
154
156
|
const incomingStickyFile = stickyArrayIndex >= 0 ? stackedFiles[stickyArrayIndex + 1] : undefined
|
|
155
157
|
const incomingHeaderDistance = incomingStickyFile ? incomingStickyFile.headerLine - stickyScrollTop : Number.POSITIVE_INFINITY
|
|
156
158
|
const incomingFile = incomingHeaderDistance === 1 ? incomingStickyFile : undefined
|
|
@@ -177,7 +179,7 @@ export const PullRequestDiffPane = ({
|
|
|
177
179
|
<box height={height} flexDirection="column">
|
|
178
180
|
<DiffPaneHeader pullRequest={pullRequest} paneWidth={paneWidth} />
|
|
179
181
|
<Divider width={paneWidth} />
|
|
180
|
-
<scrollbox ref={scrollRef}
|
|
182
|
+
<scrollbox ref={scrollRef} focusable={false} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
|
|
181
183
|
{stackedFiles.map((stackedFile) => (
|
|
182
184
|
<box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
|
|
183
185
|
{stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
|
|
@@ -234,10 +236,10 @@ export const PullRequestDiffPane = ({
|
|
|
234
236
|
<>
|
|
235
237
|
<Divider width={paneWidth} />
|
|
236
238
|
<PaddedRow>
|
|
237
|
-
<
|
|
239
|
+
<CommentSegmentsLine segments={commentPeekMeta} />
|
|
238
240
|
</PaddedRow>
|
|
239
241
|
<PaddedRow>
|
|
240
|
-
<
|
|
242
|
+
<CommentBodyLine body={commentPeek.body} width={Math.max(1, paneWidth - 2)} />
|
|
241
243
|
</PaddedRow>
|
|
242
244
|
</>
|
|
243
245
|
) : null}
|
|
@@ -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,9 +378,7 @@ 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
|
|
216
|
-
|
|
217
|
-
export const diffCommentSideLabel = (anchor: Pick<DiffCommentAnchor, "side">) => anchor.side === "RIGHT" ? "right" : "left"
|
|
381
|
+
export const diffCommentSideLabel = (anchor: Pick<DiffCommentAnchor, "side">) => anchor.side === "RIGHT" ? "→" : "←"
|
|
218
382
|
|
|
219
383
|
export const diffCommentLineLabel = (anchor: Pick<DiffCommentAnchor, "side" | "line">) => `${anchor.side === "RIGHT" ? "+" : "-"}${anchor.line}`
|
|
220
384
|
|
|
@@ -250,10 +414,6 @@ export const diffFileStats = (file: DiffFilePatch): DiffFileStats => {
|
|
|
250
414
|
return { additions, deletions }
|
|
251
415
|
}
|
|
252
416
|
|
|
253
|
-
export const diffFileStatText = (file: DiffFilePatch) => {
|
|
254
|
-
return diffFileStatsText(diffFileStats(file))
|
|
255
|
-
}
|
|
256
|
-
|
|
257
417
|
export const diffFileStatsText = (stats: DiffFileStats) => {
|
|
258
418
|
return [
|
|
259
419
|
stats.additions > 0 ? `+${stats.additions}` : null,
|
|
@@ -353,6 +513,32 @@ export const getStackedDiffCommentAnchors = (
|
|
|
353
513
|
renderLine: stackedFile.diffStartLine + anchor.renderLine,
|
|
354
514
|
})))
|
|
355
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
|
+
|
|
356
542
|
export const nearestDiffCommentAnchorIndex = (anchors: readonly DiffCommentAnchor[], renderLine: number) => {
|
|
357
543
|
if (anchors.length === 0) return 0
|
|
358
544
|
const nextIndex = anchors.findIndex((anchor) => anchor.renderLine >= renderLine)
|
package/src/ui/modals.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Data } from "effect"
|
|
2
|
-
import { formatShortDate, formatTimestamp } from "../date.js"
|
|
3
2
|
import type { PullRequestLabel, PullRequestMergeInfo, PullRequestReviewComment } from "../domain.js"
|
|
4
3
|
import { availableMergeActions } from "../mergeActions.js"
|
|
5
4
|
import { clampCursor, commentEditorLines, cursorLineIndexForLines } from "./commentEditor.js"
|
|
6
5
|
import { colors, filterThemeDefinitions, themeDefinitions, type ThemeId } from "./colors.js"
|
|
6
|
+
import { commentDisplayRows, CommentSegmentsLine, type CommentDisplayLine } from "./comments.js"
|
|
7
7
|
import { centerCell, Filler, fitCell, HintRow, PlainLine, StandardModal, standardModalDims, TextLine } from "./primitives.js"
|
|
8
8
|
import { labelColor, shortRepoName } from "./pullRequests.js"
|
|
9
9
|
|
|
@@ -484,41 +484,8 @@ export const CommentModal = ({
|
|
|
484
484
|
)
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
readonly text: string
|
|
490
|
-
readonly fg: string
|
|
491
|
-
readonly bold?: boolean
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const wrapCommentBody = (body: string, width: number) => {
|
|
495
|
-
const lines = body.length === 0 ? [""] : body.split("\n")
|
|
496
|
-
return lines.flatMap((line) => {
|
|
497
|
-
if (line.length === 0) return [""]
|
|
498
|
-
const wrapped: string[] = []
|
|
499
|
-
for (let index = 0; index < line.length; index += width) {
|
|
500
|
-
wrapped.push(line.slice(index, index + width))
|
|
501
|
-
}
|
|
502
|
-
return wrapped
|
|
503
|
-
})
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const formatCommentDate = (date: Date | null) => date ? `${formatShortDate(date)} ${formatTimestamp(date)}` : ""
|
|
507
|
-
|
|
508
|
-
const commentThreadRows = (comments: readonly PullRequestReviewComment[], width: number): readonly CommentThreadRow[] =>
|
|
509
|
-
comments.flatMap((comment, commentIndex) => {
|
|
510
|
-
const timestamp = formatCommentDate(comment.createdAt)
|
|
511
|
-
const header = timestamp ? `${comment.author} ${timestamp}` : comment.author
|
|
512
|
-
return [
|
|
513
|
-
{ key: `${comment.id}:header`, text: header, fg: colors.count, bold: true },
|
|
514
|
-
...wrapCommentBody(comment.body, width).map((line, lineIndex) => ({
|
|
515
|
-
key: `${comment.id}:body:${lineIndex}`,
|
|
516
|
-
text: line,
|
|
517
|
-
fg: colors.text,
|
|
518
|
-
})),
|
|
519
|
-
...(commentIndex < comments.length - 1 ? [{ key: `${comment.id}:gap`, text: "", fg: colors.muted }] : []),
|
|
520
|
-
]
|
|
521
|
-
})
|
|
487
|
+
const commentThreadRows = (comments: readonly PullRequestReviewComment[], width: number): readonly CommentDisplayLine[] =>
|
|
488
|
+
comments.flatMap((comment) => commentDisplayRows({ item: comment, width }))
|
|
522
489
|
|
|
523
490
|
export const CommentThreadModal = ({
|
|
524
491
|
state,
|
|
@@ -559,9 +526,7 @@ export const CommentThreadModal = ({
|
|
|
559
526
|
>
|
|
560
527
|
{visibleRows.length === 0 ? (
|
|
561
528
|
<PlainLine text={fitCell("No comments on this line.", contentWidth)} fg={colors.muted} />
|
|
562
|
-
) : visibleRows.map((row) =>
|
|
563
|
-
<PlainLine key={row.key} text={fitCell(row.text, contentWidth)} fg={row.fg} bold={row.bold ?? false} />
|
|
564
|
-
))}
|
|
529
|
+
) : visibleRows.map((row) => <CommentSegmentsLine key={row.key} segments={row.segments} />)}
|
|
565
530
|
</StandardModal>
|
|
566
531
|
)
|
|
567
532
|
}
|
package/src/ui/primitives.tsx
CHANGED
|
@@ -167,6 +167,7 @@ export const ModalFrame = ({
|
|
|
167
167
|
width,
|
|
168
168
|
height,
|
|
169
169
|
junctionRows = [],
|
|
170
|
+
topJunctionColumns = [],
|
|
170
171
|
backgroundColor = colors.modalBackground,
|
|
171
172
|
}: {
|
|
172
173
|
children: React.ReactNode
|
|
@@ -175,15 +176,18 @@ export const ModalFrame = ({
|
|
|
175
176
|
width: number
|
|
176
177
|
height: number
|
|
177
178
|
junctionRows?: readonly number[]
|
|
179
|
+
topJunctionColumns?: readonly number[]
|
|
178
180
|
backgroundColor?: string
|
|
179
181
|
}) => {
|
|
180
182
|
const innerWidth = Math.max(1, width - 2)
|
|
181
183
|
const innerHeight = Math.max(1, height - 2)
|
|
182
184
|
const junctions = new Set(junctionRows)
|
|
185
|
+
const topJunctions = new Set(topJunctionColumns)
|
|
186
|
+
const topBorder = Array.from({ length: innerWidth }, (_, index) => topJunctions.has(index) ? "┬" : "─").join("")
|
|
183
187
|
|
|
184
188
|
return (
|
|
185
189
|
<box position="absolute" left={left} top={top} width={width} height={height} flexDirection="column" backgroundColor={backgroundColor}>
|
|
186
|
-
<PlainLine text={`┌${
|
|
190
|
+
<PlainLine text={`┌${topBorder}┐`} fg={colors.separator} />
|
|
187
191
|
<box height={innerHeight} flexDirection="row">
|
|
188
192
|
<box width={1} height={innerHeight} flexDirection="column">
|
|
189
193
|
{Array.from({ length: innerHeight }, (_, index) => <PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />)}
|
|
@@ -11,7 +11,8 @@ export const singleLineText = (text: string) => text.replace(/[\r\n]+/g, " ")
|
|
|
11
11
|
|
|
12
12
|
export const printableKeyText = (key: Pick<SingleLineInputKey, "ctrl" | "meta" | "sequence">) => {
|
|
13
13
|
if (key.ctrl || key.meta || key.sequence.length === 0) return null
|
|
14
|
-
|
|
14
|
+
// oxlint-disable-next-line no-control-regex -- intentional: skip control characters
|
|
15
|
+
return /^[^\u0000-\u001f\u007f]+$/.test(key.sequence) ? key.sequence : null
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export const editSingleLineInput = (value: string, key: SingleLineInputKey) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { CliRenderer } from "@opentui/core"
|
|
2
|
-
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Creates the OpenTUI keymap with ghui's customizations.
|
|
6
|
-
*
|
|
7
|
-
* The default emacs-style parser only treats whitespace-separated input as a
|
|
8
|
-
* sequence when at least one stroke has a "+" modifier. Prepend a parser that
|
|
9
|
-
* handles plain plain-key sequences ("g g") so vim-style multi-stroke bindings
|
|
10
|
-
* can be authored directly.
|
|
11
|
-
*/
|
|
12
|
-
export const createKeymap = (renderer: CliRenderer) => {
|
|
13
|
-
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
14
|
-
keymap.prependBindingParser(({ input, index, parseObjectKey }) => {
|
|
15
|
-
if (index !== 0) return undefined
|
|
16
|
-
const strokes = input.trim().split(/\s+/).filter(Boolean)
|
|
17
|
-
if (strokes.length <= 1) return undefined
|
|
18
|
-
if (strokes.some((stroke) => stroke.includes("+"))) return undefined
|
|
19
|
-
return {
|
|
20
|
-
parts: strokes.map((stroke) => parseObjectKey({ name: stroke.toLowerCase() })),
|
|
21
|
-
nextIndex: input.length,
|
|
22
|
-
}
|
|
23
|
-
})
|
|
24
|
-
return keymap
|
|
25
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { useBindings } from "@opentui/keymap/react"
|
|
2
|
-
import type { RefObject } from "react"
|
|
3
|
-
import { useRef } from "react"
|
|
4
|
-
import type { AppCommand } from "../commands.js"
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Registers each AppCommand under its ID as a named keymap command, so
|
|
8
|
-
* bindings can reference them by ID (`cmd: "pull.refresh"`) and the keymap's
|
|
9
|
-
* introspection (queryCommands, useActiveKeys) sees our commands.
|
|
10
|
-
*
|
|
11
|
-
* The set of IDs is captured at first render — adding to the static list
|
|
12
|
-
* later in the session would not be picked up.
|
|
13
|
-
*/
|
|
14
|
-
export const useAppCommandRegistry = (
|
|
15
|
-
appCommands: readonly AppCommand[],
|
|
16
|
-
runCommandByIdRef: RefObject<(id: string, options?: { readonly notifyDisabled?: boolean }) => boolean>,
|
|
17
|
-
) => {
|
|
18
|
-
const idsRef = useRef(appCommands.map((command) => command.id))
|
|
19
|
-
|
|
20
|
-
useBindings(() => ({
|
|
21
|
-
commands: idsRef.current.map((id) => ({
|
|
22
|
-
name: id,
|
|
23
|
-
run: () => {
|
|
24
|
-
runCommandByIdRef.current(id)
|
|
25
|
-
return true
|
|
26
|
-
},
|
|
27
|
-
})),
|
|
28
|
-
bindings: [],
|
|
29
|
-
}), [])
|
|
30
|
-
}
|