@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/diff.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseColor, SyntaxStyle } from "@opentui/core"
|
|
2
|
-
import type { PullRequestItem } from "../domain.js"
|
|
2
|
+
import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
|
|
3
3
|
import { colors } from "./colors.js"
|
|
4
4
|
|
|
5
5
|
export interface DiffFilePatch {
|
|
@@ -8,6 +8,33 @@ export interface DiffFilePatch {
|
|
|
8
8
|
readonly patch: string
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export interface DiffFileStats {
|
|
12
|
+
readonly additions: number
|
|
13
|
+
readonly deletions: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StackedDiffFilePatch {
|
|
17
|
+
readonly file: DiffFilePatch
|
|
18
|
+
readonly index: number
|
|
19
|
+
readonly headerLine: number
|
|
20
|
+
readonly diffStartLine: number
|
|
21
|
+
readonly diffHeight: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DiffCommentAnchor {
|
|
25
|
+
readonly path: string
|
|
26
|
+
readonly line: number
|
|
27
|
+
readonly side: DiffCommentSide
|
|
28
|
+
readonly kind: "addition" | "deletion" | "context"
|
|
29
|
+
readonly renderLine: number
|
|
30
|
+
readonly text: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StackedDiffCommentAnchor = DiffCommentAnchor & {
|
|
34
|
+
readonly fileIndex: number
|
|
35
|
+
readonly localRenderLine: number
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
export type PullRequestDiffState =
|
|
12
39
|
| { readonly status: "loading" }
|
|
13
40
|
| { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
|
|
@@ -144,6 +171,32 @@ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
|
|
|
144
171
|
|
|
145
172
|
export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
|
|
146
173
|
|
|
174
|
+
export const safeDiffFileIndex = (files: readonly DiffFilePatch[], index: number) =>
|
|
175
|
+
files.length > 0 ? Math.max(0, Math.min(index, files.length - 1)) : 0
|
|
176
|
+
|
|
177
|
+
export const buildStackedDiffFiles = (
|
|
178
|
+
files: readonly DiffFilePatch[],
|
|
179
|
+
view: "unified" | "split",
|
|
180
|
+
wrapMode: "none" | "word",
|
|
181
|
+
width: number,
|
|
182
|
+
): readonly StackedDiffFilePatch[] => {
|
|
183
|
+
let offset = 0
|
|
184
|
+
return files.map((file, index) => {
|
|
185
|
+
const diffHeight = patchRenderableLineCount(file.patch, view, wrapMode, width)
|
|
186
|
+
const separatorBefore = index === 0 ? 0 : 1
|
|
187
|
+
const headerLine = offset + separatorBefore
|
|
188
|
+
const stackedFile = {
|
|
189
|
+
file,
|
|
190
|
+
index,
|
|
191
|
+
headerLine,
|
|
192
|
+
diffStartLine: headerLine + 2,
|
|
193
|
+
diffHeight,
|
|
194
|
+
} satisfies StackedDiffFilePatch
|
|
195
|
+
offset += separatorBefore + 2 + diffHeight
|
|
196
|
+
return stackedFile
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
147
200
|
export const diffStatText = (pullRequest: PullRequestItem) => {
|
|
148
201
|
if (!pullRequest.detailLoaded) return "loading details"
|
|
149
202
|
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
@@ -154,6 +207,153 @@ export const diffStatText = (pullRequest: PullRequestItem) => {
|
|
|
154
207
|
].filter((part): part is string => part !== null).join(" ")
|
|
155
208
|
}
|
|
156
209
|
|
|
210
|
+
export const diffCommentLocationKey = (location: Pick<PullRequestReviewComment, "path" | "side" | "line">) => `${location.path}:${location.side}:${location.line}`
|
|
211
|
+
|
|
212
|
+
export const diffCommentAnchorKey = diffCommentLocationKey
|
|
213
|
+
|
|
214
|
+
type PendingDiffCommentAnchor = Omit<DiffCommentAnchor, "renderLine">
|
|
215
|
+
|
|
216
|
+
const diffContentWidth = (lines: readonly string[], view: "unified" | "split", width: number) => {
|
|
217
|
+
const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
|
|
218
|
+
return view === "split"
|
|
219
|
+
? Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
|
|
220
|
+
: Math.max(1, width - lineNumberGutterWidth)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const diffFileStats = (file: DiffFilePatch): DiffFileStats => {
|
|
224
|
+
let additions = 0
|
|
225
|
+
let deletions = 0
|
|
226
|
+
let inHunk = false
|
|
227
|
+
|
|
228
|
+
for (const line of file.patch.split("\n")) {
|
|
229
|
+
const hunk = line.match(hunkHeaderPattern)
|
|
230
|
+
if (hunk) {
|
|
231
|
+
inHunk = true
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!inHunk) continue
|
|
236
|
+
const firstChar = line[0]
|
|
237
|
+
if (firstChar === "+") additions++
|
|
238
|
+
else if (firstChar === "-") deletions++
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { additions, deletions }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const diffFileStatText = (file: DiffFilePatch) => {
|
|
245
|
+
const stats = diffFileStats(file)
|
|
246
|
+
return [
|
|
247
|
+
stats.additions > 0 ? `+${stats.additions}` : null,
|
|
248
|
+
stats.deletions > 0 ? `-${stats.deletions}` : null,
|
|
249
|
+
].filter((part): part is string => part !== null).join(" ")
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export const getDiffCommentAnchors = (file: DiffFilePatch, view: "unified" | "split" = "unified", wrapMode: "none" | "word" = "none", width = 120): readonly DiffCommentAnchor[] => {
|
|
253
|
+
const anchors: DiffCommentAnchor[] = []
|
|
254
|
+
const lines = file.patch.split("\n")
|
|
255
|
+
const contentWidth = diffContentWidth(lines, view, width)
|
|
256
|
+
let oldLine = 0
|
|
257
|
+
let newLine = 0
|
|
258
|
+
let renderLine = 0
|
|
259
|
+
let inHunk = false
|
|
260
|
+
let deletions: Array<PendingDiffCommentAnchor & { readonly height: number }> = []
|
|
261
|
+
let additions: Array<PendingDiffCommentAnchor & { readonly height: number }> = []
|
|
262
|
+
|
|
263
|
+
const flushChangeBlock = () => {
|
|
264
|
+
if (deletions.length === 0 && additions.length === 0) return
|
|
265
|
+
if (view === "split") {
|
|
266
|
+
const rowCount = Math.max(deletions.length, additions.length)
|
|
267
|
+
for (let index = 0; index < rowCount; index++) {
|
|
268
|
+
const deletion = deletions[index]
|
|
269
|
+
const addition = additions[index]
|
|
270
|
+
if (deletion) anchors.push({ path: deletion.path, line: deletion.line, side: deletion.side, kind: deletion.kind, text: deletion.text, renderLine })
|
|
271
|
+
if (addition) anchors.push({ path: addition.path, line: addition.line, side: addition.side, kind: addition.kind, text: addition.text, renderLine })
|
|
272
|
+
renderLine += Math.max(deletion?.height ?? 1, addition?.height ?? 1)
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
for (const deletion of deletions) {
|
|
276
|
+
anchors.push({ path: deletion.path, line: deletion.line, side: deletion.side, kind: deletion.kind, text: deletion.text, renderLine })
|
|
277
|
+
renderLine += deletion.height
|
|
278
|
+
}
|
|
279
|
+
for (const addition of additions) {
|
|
280
|
+
anchors.push({ path: addition.path, line: addition.line, side: addition.side, kind: addition.kind, text: addition.text, renderLine })
|
|
281
|
+
renderLine += addition.height
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
deletions = []
|
|
285
|
+
additions = []
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const line of lines) {
|
|
289
|
+
const hunk = line.match(hunkHeaderPattern)
|
|
290
|
+
if (hunk) {
|
|
291
|
+
flushChangeBlock()
|
|
292
|
+
oldLine = Number(hunk[1])
|
|
293
|
+
newLine = Number(hunk[3])
|
|
294
|
+
inHunk = true
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!inHunk) continue
|
|
299
|
+
|
|
300
|
+
const firstChar = line[0]
|
|
301
|
+
if (firstChar === "\\") continue
|
|
302
|
+
|
|
303
|
+
if (firstChar === "+") {
|
|
304
|
+
const text = line.slice(1)
|
|
305
|
+
additions.push({ path: file.name, line: newLine, side: "RIGHT", kind: "addition", text, height: estimatedWrappedLineCount(text, contentWidth, wrapMode) })
|
|
306
|
+
newLine++
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (firstChar === "-") {
|
|
311
|
+
const text = line.slice(1)
|
|
312
|
+
deletions.push({ path: file.name, line: oldLine, side: "LEFT", kind: "deletion", text, height: estimatedWrappedLineCount(text, contentWidth, wrapMode) })
|
|
313
|
+
oldLine++
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (firstChar === " ") {
|
|
318
|
+
flushChangeBlock()
|
|
319
|
+
const text = line.slice(1)
|
|
320
|
+
anchors.push({ path: file.name, line: newLine, side: "RIGHT", kind: "context", renderLine, text })
|
|
321
|
+
oldLine++
|
|
322
|
+
newLine++
|
|
323
|
+
renderLine += estimatedWrappedLineCount(text, contentWidth, wrapMode)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
flushChangeBlock()
|
|
328
|
+
return anchors
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export const getStackedDiffCommentAnchors = (
|
|
332
|
+
stackedFiles: readonly StackedDiffFilePatch[],
|
|
333
|
+
view: "unified" | "split" = "unified",
|
|
334
|
+
wrapMode: "none" | "word" = "none",
|
|
335
|
+
width = 120,
|
|
336
|
+
): readonly StackedDiffCommentAnchor[] =>
|
|
337
|
+
stackedFiles.flatMap((stackedFile) => getDiffCommentAnchors(stackedFile.file, view, wrapMode, width).map((anchor) => ({
|
|
338
|
+
...anchor,
|
|
339
|
+
fileIndex: stackedFile.index,
|
|
340
|
+
localRenderLine: anchor.renderLine,
|
|
341
|
+
renderLine: stackedFile.diffStartLine + anchor.renderLine,
|
|
342
|
+
})))
|
|
343
|
+
|
|
344
|
+
export const nearestDiffCommentAnchorIndex = (anchors: readonly DiffCommentAnchor[], renderLine: number) => {
|
|
345
|
+
if (anchors.length === 0) return 0
|
|
346
|
+
const nextIndex = anchors.findIndex((anchor) => anchor.renderLine >= renderLine)
|
|
347
|
+
return nextIndex >= 0 ? nextIndex : anchors.length - 1
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export const scrollTopForVisibleLine = (currentTop: number, viewportHeight: number, line: number, margin = 1) => {
|
|
351
|
+
const safeViewportHeight = Math.max(1, viewportHeight)
|
|
352
|
+
if (line < currentTop + margin) return Math.max(0, line - margin)
|
|
353
|
+
if (line >= currentTop + safeViewportHeight - margin) return Math.max(0, line - safeViewportHeight + margin + 1)
|
|
354
|
+
return currentTop
|
|
355
|
+
}
|
|
356
|
+
|
|
157
357
|
const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
|
|
158
358
|
if (wrapMode === "none") return 1
|
|
159
359
|
return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
|
|
@@ -166,10 +366,10 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
|
|
|
166
366
|
let newLine = 0
|
|
167
367
|
|
|
168
368
|
for (const line of lines) {
|
|
169
|
-
const hunk = line.match(
|
|
369
|
+
const hunk = line.match(hunkHeaderPattern)
|
|
170
370
|
if (hunk) {
|
|
171
371
|
oldLine = Number(hunk[1])
|
|
172
|
-
newLine = Number(hunk[
|
|
372
|
+
newLine = Number(hunk[3])
|
|
173
373
|
maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
|
|
174
374
|
continue
|
|
175
375
|
}
|
|
@@ -196,10 +396,7 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
|
|
|
196
396
|
|
|
197
397
|
export const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
|
|
198
398
|
const lines = patch.split("\n")
|
|
199
|
-
const
|
|
200
|
-
const splitPaneWidth = Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
|
|
201
|
-
const unifiedPaneWidth = Math.max(1, width - lineNumberGutterWidth)
|
|
202
|
-
const contentWidth = view === "split" ? splitPaneWidth : unifiedPaneWidth
|
|
399
|
+
const contentWidth = diffContentWidth(lines, view, width)
|
|
203
400
|
let count = 0
|
|
204
401
|
let inHunk = false
|
|
205
402
|
let deletions: number[] = []
|
package/src/ui/modals.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
|
-
import
|
|
2
|
+
import { Data } from "effect"
|
|
3
|
+
import { formatShortDate, formatTimestamp } from "../date.js"
|
|
4
|
+
import type { PullRequestLabel, PullRequestMergeInfo, PullRequestReviewComment } from "../domain.js"
|
|
3
5
|
import { availableMergeActions } from "../mergeActions.js"
|
|
6
|
+
import { clampCursor, commentEditorLines, cursorLineIndexForLines } from "./commentEditor.js"
|
|
4
7
|
import { colors, filterThemeDefinitions, themeDefinitions, type ThemeId } from "./colors.js"
|
|
5
8
|
import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
|
|
6
9
|
import { labelColor, shortRepoName } from "./pullRequests.js"
|
|
7
10
|
|
|
8
11
|
export interface LabelModalState {
|
|
9
|
-
readonly open: boolean
|
|
10
12
|
readonly repository: string | null
|
|
11
13
|
readonly query: string
|
|
12
14
|
readonly selectedIndex: number
|
|
@@ -15,7 +17,6 @@ export interface LabelModalState {
|
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface MergeModalState {
|
|
18
|
-
readonly open: boolean
|
|
19
20
|
readonly repository: string | null
|
|
20
21
|
readonly number: number | null
|
|
21
22
|
readonly selectedIndex: number
|
|
@@ -26,7 +27,6 @@ export interface MergeModalState {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export interface CloseModalState {
|
|
29
|
-
readonly open: boolean
|
|
30
30
|
readonly repository: string | null
|
|
31
31
|
readonly number: number | null
|
|
32
32
|
readonly title: string
|
|
@@ -35,15 +35,23 @@ export interface CloseModalState {
|
|
|
35
35
|
readonly error: string | null
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export interface CommentModalState {
|
|
39
|
+
readonly body: string
|
|
40
|
+
readonly cursor: number
|
|
41
|
+
readonly error: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CommentThreadModalState {
|
|
45
|
+
readonly scrollOffset: number
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
export interface ThemeModalState {
|
|
39
|
-
readonly open: boolean
|
|
40
49
|
readonly query: string
|
|
41
50
|
readonly filterMode: boolean
|
|
42
51
|
readonly initialThemeId: ThemeId
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
export const initialLabelModalState: LabelModalState = {
|
|
46
|
-
open: false,
|
|
47
55
|
repository: null,
|
|
48
56
|
query: "",
|
|
49
57
|
selectedIndex: 0,
|
|
@@ -52,7 +60,6 @@ export const initialLabelModalState: LabelModalState = {
|
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
export const initialMergeModalState: MergeModalState = {
|
|
55
|
-
open: false,
|
|
56
63
|
repository: null,
|
|
57
64
|
number: null,
|
|
58
65
|
selectedIndex: 0,
|
|
@@ -63,7 +70,6 @@ export const initialMergeModalState: MergeModalState = {
|
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
export const initialCloseModalState: CloseModalState = {
|
|
66
|
-
open: false,
|
|
67
73
|
repository: null,
|
|
68
74
|
number: null,
|
|
69
75
|
title: "",
|
|
@@ -72,13 +78,47 @@ export const initialCloseModalState: CloseModalState = {
|
|
|
72
78
|
error: null,
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
export const initialCommentModalState: CommentModalState = {
|
|
82
|
+
body: "",
|
|
83
|
+
cursor: 0,
|
|
84
|
+
error: null,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const initialCommentThreadModalState: CommentThreadModalState = {
|
|
88
|
+
scrollOffset: 0,
|
|
89
|
+
}
|
|
90
|
+
|
|
75
91
|
export const initialThemeModalState: ThemeModalState = {
|
|
76
|
-
open: false,
|
|
77
92
|
query: "",
|
|
78
93
|
filterMode: false,
|
|
79
94
|
initialThemeId: "ghui",
|
|
80
95
|
}
|
|
81
96
|
|
|
97
|
+
export type Modal = Data.TaggedEnum<{
|
|
98
|
+
None: {}
|
|
99
|
+
Label: { readonly state: LabelModalState }
|
|
100
|
+
Close: { readonly state: CloseModalState }
|
|
101
|
+
Merge: { readonly state: MergeModalState }
|
|
102
|
+
Comment: { readonly state: CommentModalState }
|
|
103
|
+
CommentThread: { readonly state: CommentThreadModalState }
|
|
104
|
+
Theme: { readonly state: ThemeModalState }
|
|
105
|
+
}>
|
|
106
|
+
|
|
107
|
+
export const Modal = Data.taggedEnum<Modal>()
|
|
108
|
+
export const initialModal: Modal = Modal.None()
|
|
109
|
+
|
|
110
|
+
export type ModalTag = Modal["_tag"]
|
|
111
|
+
export type ModalState<Tag extends Exclude<ModalTag, "None">> = Extract<Modal, { _tag: Tag }>["state"]
|
|
112
|
+
|
|
113
|
+
export const modalInitialStates = {
|
|
114
|
+
Label: initialLabelModalState,
|
|
115
|
+
Close: initialCloseModalState,
|
|
116
|
+
Merge: initialMergeModalState,
|
|
117
|
+
Comment: initialCommentModalState,
|
|
118
|
+
CommentThread: initialCommentThreadModalState,
|
|
119
|
+
Theme: initialThemeModalState,
|
|
120
|
+
} as const satisfies { [Tag in Exclude<ModalTag, "None">]: ModalState<Tag> }
|
|
121
|
+
|
|
82
122
|
const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
|
|
83
123
|
if (!info) return "Loading merge status from GitHub."
|
|
84
124
|
if (info.state !== "open") return "This pull request is not open."
|
|
@@ -349,6 +389,193 @@ export const CloseModal = ({
|
|
|
349
389
|
)
|
|
350
390
|
}
|
|
351
391
|
|
|
392
|
+
export const CommentModal = ({
|
|
393
|
+
state,
|
|
394
|
+
anchorLabel,
|
|
395
|
+
modalWidth,
|
|
396
|
+
modalHeight,
|
|
397
|
+
offsetLeft,
|
|
398
|
+
offsetTop,
|
|
399
|
+
}: {
|
|
400
|
+
state: CommentModalState
|
|
401
|
+
anchorLabel: string
|
|
402
|
+
modalWidth: number
|
|
403
|
+
modalHeight: number
|
|
404
|
+
offsetLeft: number
|
|
405
|
+
offsetTop: number
|
|
406
|
+
}) => {
|
|
407
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
408
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
409
|
+
const title = "Comment"
|
|
410
|
+
const rightText = "enter save"
|
|
411
|
+
const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
|
|
412
|
+
const bodyHeight = Math.max(1, modalHeight - 7)
|
|
413
|
+
const editorHeight = Math.max(1, bodyHeight - (state.error ? 1 : 0))
|
|
414
|
+
const lineRanges = commentEditorLines(state.body)
|
|
415
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
416
|
+
const cursorLineIndex = cursorLineIndexForLines(lineRanges, cursor)
|
|
417
|
+
const visibleStart = Math.min(
|
|
418
|
+
Math.max(0, lineRanges.length - editorHeight),
|
|
419
|
+
Math.max(0, cursorLineIndex - editorHeight + 1),
|
|
420
|
+
)
|
|
421
|
+
const visibleLines = lineRanges.slice(visibleStart, visibleStart + editorHeight)
|
|
422
|
+
const renderEditorLine = (line: { readonly text: string; readonly start: number; readonly end: number }, index: number) => {
|
|
423
|
+
const lineIndex = visibleStart + index
|
|
424
|
+
const isCursorLine = lineIndex === cursorLineIndex
|
|
425
|
+
const cursorColumn = Math.max(0, Math.min(cursor - line.start, line.text.length))
|
|
426
|
+
const viewStart = isCursorLine ? Math.max(0, cursorColumn - contentWidth + 1) : 0
|
|
427
|
+
const visibleText = line.text.slice(viewStart, viewStart + contentWidth)
|
|
428
|
+
|
|
429
|
+
if (!isCursorLine) {
|
|
430
|
+
return <PlainLine key={lineIndex} text={fitCell(visibleText, contentWidth)} fg={state.body.length > 0 ? colors.text : colors.muted} />
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const cursorInView = cursorColumn - viewStart
|
|
434
|
+
const before = visibleText.slice(0, cursorInView)
|
|
435
|
+
const placeholder = state.body.length === 0 ? "Write a comment..." : ""
|
|
436
|
+
const cursorChar = placeholder ? placeholder[0] ?? " " : visibleText[cursorInView] ?? " "
|
|
437
|
+
const after = placeholder ? placeholder.slice(1) : visibleText.slice(cursorInView + 1)
|
|
438
|
+
|
|
439
|
+
return (
|
|
440
|
+
<TextLine key={lineIndex}>
|
|
441
|
+
{before ? <span fg={colors.text}>{before}</span> : null}
|
|
442
|
+
<span bg={colors.accent} fg={colors.background}>{cursorChar}</span>
|
|
443
|
+
{after ? <span fg={placeholder ? colors.muted : colors.text}>{after}</span> : null}
|
|
444
|
+
</TextLine>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
450
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
451
|
+
<TextLine>
|
|
452
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
453
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
454
|
+
<span fg={colors.muted}>{rightText}</span>
|
|
455
|
+
</TextLine>
|
|
456
|
+
</box>
|
|
457
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
458
|
+
<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />
|
|
459
|
+
</box>
|
|
460
|
+
<Divider width={innerWidth} />
|
|
461
|
+
<box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
462
|
+
{state.error ? <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} /> : null}
|
|
463
|
+
{visibleLines.map(renderEditorLine)}
|
|
464
|
+
</box>
|
|
465
|
+
<Divider width={innerWidth} />
|
|
466
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
467
|
+
<TextLine>
|
|
468
|
+
<span fg={colors.count}>enter</span>
|
|
469
|
+
<span fg={colors.muted}> save </span>
|
|
470
|
+
<span fg={colors.count}>shift-enter</span>
|
|
471
|
+
<span fg={colors.muted}> newline </span>
|
|
472
|
+
<span fg={colors.count}>esc</span>
|
|
473
|
+
<span fg={colors.muted}> cancel</span>
|
|
474
|
+
</TextLine>
|
|
475
|
+
</box>
|
|
476
|
+
</ModalFrame>
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
type CommentThreadRow = {
|
|
481
|
+
readonly key: string
|
|
482
|
+
readonly text: string
|
|
483
|
+
readonly fg: string
|
|
484
|
+
readonly bold?: boolean
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const wrapCommentBody = (body: string, width: number) => {
|
|
488
|
+
const lines = body.length === 0 ? [""] : body.split("\n")
|
|
489
|
+
return lines.flatMap((line) => {
|
|
490
|
+
if (line.length === 0) return [""]
|
|
491
|
+
const wrapped: string[] = []
|
|
492
|
+
for (let index = 0; index < line.length; index += width) {
|
|
493
|
+
wrapped.push(line.slice(index, index + width))
|
|
494
|
+
}
|
|
495
|
+
return wrapped
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const formatCommentDate = (date: Date | null) => date ? `${formatShortDate(date)} ${formatTimestamp(date)}` : ""
|
|
500
|
+
|
|
501
|
+
const commentThreadRows = (comments: readonly PullRequestReviewComment[], width: number): readonly CommentThreadRow[] =>
|
|
502
|
+
comments.flatMap((comment, commentIndex) => {
|
|
503
|
+
const timestamp = formatCommentDate(comment.createdAt)
|
|
504
|
+
const header = timestamp ? `${comment.author} ${timestamp}` : comment.author
|
|
505
|
+
return [
|
|
506
|
+
{ key: `${comment.id}:header`, text: header, fg: colors.count, bold: true },
|
|
507
|
+
...wrapCommentBody(comment.body, width).map((line, lineIndex) => ({
|
|
508
|
+
key: `${comment.id}:body:${lineIndex}`,
|
|
509
|
+
text: line,
|
|
510
|
+
fg: colors.text,
|
|
511
|
+
})),
|
|
512
|
+
...(commentIndex < comments.length - 1 ? [{ key: `${comment.id}:gap`, text: "", fg: colors.muted }] : []),
|
|
513
|
+
]
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
export const CommentThreadModal = ({
|
|
517
|
+
state,
|
|
518
|
+
anchorLabel,
|
|
519
|
+
comments,
|
|
520
|
+
modalWidth,
|
|
521
|
+
modalHeight,
|
|
522
|
+
offsetLeft,
|
|
523
|
+
offsetTop,
|
|
524
|
+
}: {
|
|
525
|
+
state: CommentThreadModalState
|
|
526
|
+
anchorLabel: string
|
|
527
|
+
comments: readonly PullRequestReviewComment[]
|
|
528
|
+
modalWidth: number
|
|
529
|
+
modalHeight: number
|
|
530
|
+
offsetLeft: number
|
|
531
|
+
offsetTop: number
|
|
532
|
+
}) => {
|
|
533
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
534
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
535
|
+
const title = "Thread"
|
|
536
|
+
const countText = comments.length === 1 ? "1 comment" : `${comments.length} comments`
|
|
537
|
+
const headerGap = Math.max(1, contentWidth - title.length - countText.length)
|
|
538
|
+
const bodyHeight = Math.max(1, modalHeight - 7)
|
|
539
|
+
const rows = commentThreadRows(comments, contentWidth)
|
|
540
|
+
const maxScroll = Math.max(0, rows.length - bodyHeight)
|
|
541
|
+
const scrollOffset = Math.max(0, Math.min(state.scrollOffset, maxScroll))
|
|
542
|
+
const visibleRows = rows.slice(scrollOffset, scrollOffset + bodyHeight)
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
546
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
547
|
+
<TextLine>
|
|
548
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
549
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
550
|
+
<span fg={colors.muted}>{countText}</span>
|
|
551
|
+
</TextLine>
|
|
552
|
+
</box>
|
|
553
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
554
|
+
<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />
|
|
555
|
+
</box>
|
|
556
|
+
<Divider width={innerWidth} />
|
|
557
|
+
<box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
558
|
+
{visibleRows.length === 0 ? (
|
|
559
|
+
<PlainLine text={fitCell("No comments on this line.", contentWidth)} fg={colors.muted} />
|
|
560
|
+
) : visibleRows.map((row) => (
|
|
561
|
+
<PlainLine key={row.key} text={fitCell(row.text, contentWidth)} fg={row.fg} bold={row.bold ?? false} />
|
|
562
|
+
))}
|
|
563
|
+
</box>
|
|
564
|
+
<Divider width={innerWidth} />
|
|
565
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
566
|
+
<TextLine>
|
|
567
|
+
<span fg={colors.count}>↑↓</span>
|
|
568
|
+
<span fg={colors.muted}> scroll </span>
|
|
569
|
+
<span fg={colors.count}>a</span>
|
|
570
|
+
<span fg={colors.muted}> comment </span>
|
|
571
|
+
<span fg={colors.count}>esc</span>
|
|
572
|
+
<span fg={colors.muted}> close</span>
|
|
573
|
+
</TextLine>
|
|
574
|
+
</box>
|
|
575
|
+
</ModalFrame>
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
352
579
|
export const ThemeModal = ({
|
|
353
580
|
state,
|
|
354
581
|
activeThemeId,
|