@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.
@@ -15,7 +15,7 @@ interface HintsContext {
15
15
  readonly showFilterClear: boolean
16
16
  readonly detailFullView: boolean
17
17
  readonly diffFullView: boolean
18
- readonly diffCommentMode: boolean
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 diffCommentModeHints: readonly HintItem[] = [
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: "v", label: "view" },
50
- { key: "w", label: "wrap" },
51
- { key: "c", label: "comment" },
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.diffCommentMode ? diffCommentModeHints : diffViewHints
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 { createDiffSyntaxStyle, 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"
@@ -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 selectedSideLabel = selectedCommentAnchor?.side === "RIGHT" ? "right" : selectedCommentAnchor?.side === "LEFT" ? "left" : null
137
- const commentPeek = commentMode && selectedCommentAnchor && selectedCommentThread.length > 0
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
- ? `${selectedSideLabel ?? "line"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line} ${commentPeek.author} ${commentPeekCount} enter thread a comment`
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 stickyFile = stackedDiffFileAtLine(stackedFiles, stickyScrollTop) ?? stackedFiles[0]
147
- const stickyArrayIndex = stickyFile ? stackedFiles.indexOf(stickyFile) : -1
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 (!commentMode) return ""
153
- if (!selectedCommentAnchor) return " c no lines"
160
+ if (!selectedCommentAnchor) return " no lines"
154
161
  if (selectedCommentAnchor.fileIndex !== stackedFile?.index) return ""
155
- return ` ${selectedCommentAnchor.side === "RIGHT" ? "right" : "left"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
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} focused={!commentMode} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
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
- <PlainLine text={fitCell(commentPeekMeta, Math.max(1, paneWidth - 2))} fg={colors.count} />
239
+ <CommentSegmentsLine segments={commentPeekMeta} />
233
240
  </PaddedRow>
234
241
  <PaddedRow>
235
- <PlainLine text={fitCell(commentPeekBody, Math.max(1, paneWidth - 2))} fg={colors.text} />
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 ? `- Loading more pull requests... (${loadedCount} loaded)` : `- ${loadedCount} loaded, more available` })
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.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,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 diffCommentAnchorKey = diffCommentLocationKey
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)