@kitlangton/ghui 0.1.22 → 0.2.1

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.
@@ -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 { createDiffSyntaxStyle, diffCommentAnchorLabel, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileAtLine, type DiffFileStats, type DiffView, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
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
- ? `${selectedCommentLabel ?? selectedSideLabel ?? "line"} ${commentPeek.author} ${commentPeekCount} enter thread`
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 stickyFile = stackedDiffFileAtLine(stackedFiles, stickyScrollTop) ?? stackedFiles[0]
153
- const stickyArrayIndex = stickyFile ? stackedFiles.indexOf(stickyFile) : -1
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} focused flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
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
- <PlainLine text={fitCell(commentPeekMeta, Math.max(1, paneWidth - 2))} fg={colors.count} />
239
+ <CommentSegmentsLine segments={commentPeekMeta} />
238
240
  </PaddedRow>
239
241
  <PaddedRow>
240
- <PlainLine text={fitCell(commentPeekBody, Math.max(1, paneWidth - 2))} fg={colors.text} />
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.reduce<StackedDiffFilePatch | undefined>((current, file) => file.headerLine <= line ? file : current, undefined)
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 diffCommentAnchorKey = diffCommentLocationKey
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
- type CommentThreadRow = {
488
- readonly key: string
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
  }
@@ -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={`┌${"─".repeat(innerWidth)}┐`} fg={colors.separator} />
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
- return /^[^\x00-\x1f\x7f]+$/.test(key.sequence) ? key.sequence : null
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
- }