@kitlangton/ghui 0.1.18 → 0.1.20

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.
@@ -1,19 +1,25 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
- import type { PullRequestItem } from "../domain.js"
2
+ import type { LoadStatus, PullRequestItem } from "../domain.js"
3
3
  import { daysOpen } from "../date.js"
4
4
  import { colors } from "./colors.js"
5
5
  import { fitCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
6
- import { checkLabel, repoColor, reviewIcon, statusColor } from "./pullRequests.js"
6
+ import { pullRequestRowDisplay, repoColor, reviewIcon } from "./pullRequests.js"
7
7
 
8
- export type LoadStatus = "loading" | "ready" | "error"
9
8
  export type PullRequestGroups = Array<[string, PullRequestItem[]]>
10
9
 
10
+ export type PullRequestListRow =
11
+ | { readonly _tag: "title" }
12
+ | { readonly _tag: "filter" }
13
+ | { readonly _tag: "message"; readonly text: string; readonly color: string }
14
+ | { readonly _tag: "group"; readonly repository: string; readonly pullRequests: readonly PullRequestItem[] }
15
+ | { readonly _tag: "pull-request"; readonly pullRequest: PullRequestItem; readonly groupPullRequests: readonly PullRequestItem[] }
16
+ | { readonly _tag: "load-more"; readonly text: string }
17
+
11
18
  const GROUP_ICON = "◆"
12
19
 
13
- const getRowLayout = (contentWidth: number, numberWidth = 6) => {
20
+ const getRowLayout = (contentWidth: number, numberWidth: number, ageWidth: number) => {
14
21
  const reviewWidth = 1
15
22
  const checkWidth = 6
16
- const ageWidth = 4
17
23
  const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth
18
24
  const titleWidth = Math.max(8, contentWidth - fixedWidth)
19
25
  return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
@@ -25,6 +31,12 @@ const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
25
31
  return maxLen + 1
26
32
  }
27
33
 
34
+ const groupAgeWidth = (pullRequests: readonly PullRequestItem[]) => {
35
+ if (pullRequests.length === 0) return 4
36
+ const maxLen = Math.max(...pullRequests.map((pr) => `${daysOpen(pr.createdAt)}d`.length))
37
+ return Math.max(4, maxLen + 1)
38
+ }
39
+
28
40
  const MatchedCell = ({ text, width, query, align = "left" }: { text: string; width: number; query: string; align?: "left" | "right" }) => {
29
41
  const fitted = fitCell(text, width, align)
30
42
  const needle = query.trim().toLowerCase()
@@ -48,11 +60,53 @@ const GroupTitle = ({ label, color, filterText }: { label: string; color: string
48
60
  </TextLine>
49
61
  )
50
62
 
63
+ export const buildPullRequestListRows = ({
64
+ groups,
65
+ status,
66
+ error,
67
+ filterText,
68
+ showFilterBar,
69
+ loadedCount,
70
+ hasMore,
71
+ isLoadingMore,
72
+ }: {
73
+ readonly groups: PullRequestGroups
74
+ readonly status: LoadStatus
75
+ readonly error: string | null
76
+ readonly filterText: string
77
+ readonly showFilterBar: boolean
78
+ readonly loadedCount: number
79
+ readonly hasMore: boolean
80
+ readonly isLoadingMore: boolean
81
+ }): readonly PullRequestListRow[] => {
82
+ const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
83
+ const rows: PullRequestListRow[] = [{ _tag: "title" }]
84
+ if (showFilterBar) rows.push({ _tag: "filter" })
85
+ if (status === "loading" && itemCount === 0) rows.push({ _tag: "message", text: "- Loading pull requests...", color: colors.muted })
86
+ if (status === "error") rows.push({ _tag: "message", text: `- ${error ?? "Could not load pull requests."}`, color: colors.error })
87
+ if (status === "ready" && itemCount === 0) rows.push({ _tag: "message", text: filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests.", color: colors.muted })
88
+ for (const [repository, pullRequests] of groups) {
89
+ rows.push({ _tag: "group", repository, pullRequests })
90
+ for (const pullRequest of pullRequests) rows.push({ _tag: "pull-request", pullRequest, groupPullRequests: pullRequests })
91
+ }
92
+ 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` })
94
+ }
95
+ return rows
96
+ }
97
+
98
+ export const pullRequestListRowIndex = (rows: readonly PullRequestListRow[], url: string | null) => {
99
+ if (!url) return null
100
+ const index = rows.findIndex((row) => row._tag === "pull-request" && row.pullRequest.url === url)
101
+ return index >= 0 ? index : null
102
+ }
103
+
51
104
  const PullRequestRow = ({
52
105
  pullRequest,
53
106
  selected,
54
107
  contentWidth,
55
108
  numWidth,
109
+ ageColWidth,
56
110
  filterText,
57
111
  onSelect,
58
112
  }: {
@@ -60,31 +114,25 @@ const PullRequestRow = ({
60
114
  selected: boolean
61
115
  contentWidth: number
62
116
  numWidth: number
117
+ ageColWidth: number
63
118
  filterText: string
64
119
  onSelect: () => void
65
120
  }) => {
66
- const isClosed = pullRequest.state === "closed"
67
- const isMerged = pullRequest.state === "merged"
68
- const isFinal = isClosed || isMerged
69
- const checkText = isMerged ? "merged" : isClosed ? "closed" : checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
70
121
  const ageText = `${daysOpen(pullRequest.createdAt)}d`
71
- const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
122
+ const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth, ageColWidth)
72
123
  const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
73
124
  const fillerWidth = Math.max(0, contentWidth - rowWidth)
74
- const indicatorColor = isMerged ? colors.status.passing : isClosed ? colors.muted : pullRequest.autoMergeEnabled ? colors.accent : statusColor(pullRequest.reviewStatus)
75
- const rowTextColor = selected ? colors.selectedText : isFinal ? colors.muted : colors.text
76
- const numberColor = selected ? colors.accent : isFinal ? colors.muted : colors.count
77
- const checkColor = isMerged ? colors.status.passing : isClosed ? colors.muted : statusColor(pullRequest.checkStatus)
125
+ const display = pullRequestRowDisplay(pullRequest, selected)
78
126
 
79
127
  return (
80
- <box height={1} onMouseDown={onSelect}>
81
- <TextLine fg={rowTextColor} bg={selected ? colors.selectedBg : undefined}>
82
- <span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
128
+ <box width={contentWidth} height={1} onMouseDown={onSelect}>
129
+ <TextLine width={contentWidth} fg={display.rowFg} bg={selected ? colors.selectedBg : undefined}>
130
+ <span fg={display.indicatorFg}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
83
131
  <span> </span>
84
- <span fg={numberColor}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
132
+ <span fg={display.numberFg}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
85
133
  <span> </span>
86
134
  <span><MatchedCell text={pullRequest.title} width={titleWidth} query={filterText} /></span>
87
- <span fg={checkColor}>{fitCell(checkText, checkWidth, "right")}</span>
135
+ <span fg={display.checkFg}>{fitCell(display.checkText, checkWidth, "right")}</span>
88
136
  <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
89
137
  {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
90
138
  </TextLine>
@@ -101,6 +149,9 @@ export const PullRequestList = ({
101
149
  filterText,
102
150
  showFilterBar,
103
151
  isFilterEditing,
152
+ loadedCount,
153
+ hasMore,
154
+ isLoadingMore,
104
155
  onSelectPullRequest,
105
156
  }: {
106
157
  groups: PullRequestGroups
@@ -111,41 +162,43 @@ export const PullRequestList = ({
111
162
  filterText: string
112
163
  showFilterBar: boolean
113
164
  isFilterEditing: boolean
165
+ loadedCount: number
166
+ hasMore: boolean
167
+ isLoadingMore: boolean
114
168
  onSelectPullRequest: (url: string) => void
115
169
  }) => {
116
- const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
117
- const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
170
+ const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, loadedCount, hasMore, isLoadingMore })
118
171
 
119
172
  return (
120
- <box flexDirection="column">
121
- <SectionTitle title="PULL REQUESTS" />
122
- {showFilterBar ? (
123
- <TextLine>
124
- <span fg={colors.count}>/</span>
125
- <span fg={colors.muted}> </span>
126
- <span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
127
- </TextLine>
128
- ) : null}
129
- {status === "loading" && itemCount === 0 ? <PlainLine text="- Loading pull requests..." fg={colors.muted} /> : null}
130
- {status === "error" ? <PlainLine text={`- ${error ?? "Could not load pull requests."}`} fg={colors.error} /> : null}
131
- {status === "ready" && itemCount === 0 ? <PlainLine text={emptyText} fg={colors.muted} /> : null}
132
- {groups.map(([repo, pullRequests]) => {
133
- const numWidth = groupNumberWidth(pullRequests)
173
+ <box width={contentWidth} flexDirection="column">
174
+ {rows.map((row, index) => {
175
+ if (row._tag === "title") return <SectionTitle key="title" title="PULL REQUESTS" />
176
+ if (row._tag === "filter") {
177
+ return (
178
+ <TextLine key="filter">
179
+ <span fg={colors.count}>/</span>
180
+ <span fg={colors.muted}> </span>
181
+ <span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
182
+ </TextLine>
183
+ )
184
+ }
185
+ if (row._tag === "message") return <PlainLine key={`message-${index}`} text={row.text} fg={row.color} />
186
+ if (row._tag === "load-more") return <PlainLine key="load-more" text={row.text} fg={colors.muted} />
187
+ if (row._tag === "group") return <GroupTitle key={`group-${row.repository}`} label={row.repository} color={repoColor(row.repository)} filterText={filterText} />
188
+
189
+ const numWidth = groupNumberWidth(row.groupPullRequests)
190
+ const ageColWidth = groupAgeWidth(row.groupPullRequests)
134
191
  return (
135
- <box key={repo} flexDirection="column">
136
- <GroupTitle label={repo} color={repoColor(repo)} filterText={filterText} />
137
- {pullRequests.map((pullRequest) => (
138
- <PullRequestRow
139
- key={pullRequest.url}
140
- pullRequest={pullRequest}
141
- selected={pullRequest.url === selectedUrl}
142
- contentWidth={contentWidth}
143
- numWidth={numWidth}
144
- filterText={filterText}
145
- onSelect={() => onSelectPullRequest(pullRequest.url)}
146
- />
147
- ))}
148
- </box>
192
+ <PullRequestRow
193
+ key={row.pullRequest.url}
194
+ pullRequest={row.pullRequest}
195
+ selected={row.pullRequest.url === selectedUrl}
196
+ contentWidth={contentWidth}
197
+ numWidth={numWidth}
198
+ ageColWidth={ageColWidth}
199
+ filterText={filterText}
200
+ onSelect={() => onSelectPullRequest(row.pullRequest.url)}
201
+ />
149
202
  )
150
203
  })}
151
204
  </box>
package/src/ui/colors.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type ThemeId =
2
+ | "system"
2
3
  | "ghui"
3
4
  | "tokyo-night"
4
5
  | "catppuccin"
@@ -60,6 +61,99 @@ export interface ThemeDefinition {
60
61
  readonly colors: ColorPalette
61
62
  }
62
63
 
64
+ interface TerminalThemeColors {
65
+ readonly palette: readonly (string | null)[]
66
+ readonly defaultForeground: string | null
67
+ readonly defaultBackground: string | null
68
+ readonly highlightBackground: string | null
69
+ readonly highlightForeground: string | null
70
+ }
71
+
72
+ const readableHex = (value: string | null | undefined, fallback: string) =>
73
+ typeof value === "string" && /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/.test(value) ? value : fallback
74
+
75
+ const hexToRgb = (hex: string) => {
76
+ const value = hex.replace(/^#/, "").slice(0, 6)
77
+ return {
78
+ r: parseInt(value.slice(0, 2), 16),
79
+ g: parseInt(value.slice(2, 4), 16),
80
+ b: parseInt(value.slice(4, 6), 16),
81
+ }
82
+ }
83
+
84
+ const luminance = (hex: string) => {
85
+ const { r, g, b } = hexToRgb(hex)
86
+ return 0.299 * r + 0.587 * g + 0.114 * b
87
+ }
88
+
89
+ const rgbToHex = ({ r, g, b }: { readonly r: number; readonly g: number; readonly b: number }) =>
90
+ `#${[r, g, b].map((component) => Math.max(0, Math.min(255, Math.round(component))).toString(16).padStart(2, "0")).join("")}`
91
+
92
+ export const mixHex = (base: string, overlay: string, amount: number) => {
93
+ const from = hexToRgb(base)
94
+ const to = hexToRgb(overlay)
95
+ return rgbToHex({
96
+ r: from.r + (to.r - from.r) * amount,
97
+ g: from.g + (to.g - from.g) * amount,
98
+ b: from.b + (to.b - from.b) * amount,
99
+ })
100
+ }
101
+
102
+ const grayscaleRamp = (background: string) => {
103
+ const bg = hexToRgb(background)
104
+ const bgLum = luminance(background)
105
+ const isDark = bgLum < 128
106
+ const grays: Record<number, string> = {}
107
+
108
+ for (let i = 1; i <= 12; i++) {
109
+ const factor = i / 12
110
+ let r: number
111
+ let g: number
112
+ let b: number
113
+
114
+ if (isDark) {
115
+ if (bgLum < 10) {
116
+ const value = Math.floor(factor * 0.4 * 255)
117
+ r = value
118
+ g = value
119
+ b = value
120
+ } else {
121
+ const nextLum = bgLum + (255 - bgLum) * factor * 0.4
122
+ const ratio = nextLum / bgLum
123
+ r = Math.min(bg.r * ratio, 255)
124
+ g = Math.min(bg.g * ratio, 255)
125
+ b = Math.min(bg.b * ratio, 255)
126
+ }
127
+ } else if (bgLum > 245) {
128
+ const value = Math.floor(255 - factor * 0.4 * 255)
129
+ r = value
130
+ g = value
131
+ b = value
132
+ } else {
133
+ const nextLum = bgLum * (1 - factor * 0.4)
134
+ const ratio = nextLum / bgLum
135
+ r = Math.max(bg.r * ratio, 0)
136
+ g = Math.max(bg.g * ratio, 0)
137
+ b = Math.max(bg.b * ratio, 0)
138
+ }
139
+
140
+ grays[i] = rgbToHex({ r, g, b })
141
+ }
142
+
143
+ return grays
144
+ }
145
+
146
+ const mutedTextColor = (background: string) => {
147
+ const bgLum = luminance(background)
148
+ const isDark = bgLum < 128
149
+ const value = isDark
150
+ ? bgLum < 10 ? 180 : Math.min(Math.floor(160 + bgLum * 0.3), 200)
151
+ : bgLum > 245 ? 75 : Math.max(Math.floor(100 - (255 - bgLum) * 0.2), 60)
152
+ return rgbToHex({ r: value, g: value, b: value })
153
+ }
154
+
155
+ const contrastText = (background: string) => luminance(background) > 128 ? "#000000" : "#ffffff"
156
+
63
157
  const ghuiColors: ColorPalette = {
64
158
  background: "#111018",
65
159
  modalBackground: "#1a1a2e",
@@ -99,6 +193,70 @@ const ghuiColors: ColorPalette = {
99
193
  },
100
194
  }
101
195
 
196
+ const makeSystemColors = (terminal?: TerminalThemeColors): ColorPalette => {
197
+ const palette = terminal?.palette ?? []
198
+ const terminalBackground = readableHex(terminal?.defaultBackground, readableHex(palette[0], "#000000"))
199
+ const text = readableHex(terminal?.defaultForeground, readableHex(palette[7], "#ffffff"))
200
+ const grays = grayscaleRamp(terminalBackground)
201
+ const isDark = luminance(terminalBackground) < 128
202
+ const red = readableHex(palette[1], "#cc0000")
203
+ const green = readableHex(palette[2], "#4e9a06")
204
+ const yellow = readableHex(palette[3], "#c4a000")
205
+ const blue = readableHex(palette[4], "#3465a4")
206
+ const magenta = readableHex(palette[5], "#75507b")
207
+ const brightBlack = readableHex(palette[8], mutedTextColor(terminalBackground))
208
+ const brightGreen = readableHex(palette[10], green)
209
+ const brightBlue = readableHex(palette[12], blue)
210
+ const brightMagenta = readableHex(palette[13], magenta)
211
+ const primary = brightBlue
212
+ const panel = grays[2] ?? mixHex(terminalBackground, text, isDark ? 0.07 : 0.08)
213
+ const element = grays[3] ?? mixHex(terminalBackground, text, isDark ? 0.1 : 0.1)
214
+ const border = grays[7] ?? mixHex(terminalBackground, text, isDark ? 0.24 : 0.24)
215
+ const borderSubtle = grays[6] ?? border
216
+ const diffAlpha = isDark ? 0.22 : 0.14
217
+
218
+ return {
219
+ background: "transparent",
220
+ modalBackground: panel,
221
+ text,
222
+ muted: mutedTextColor(terminalBackground),
223
+ separator: border,
224
+ accent: primary,
225
+ inlineCode: brightGreen,
226
+ error: red,
227
+ selectedBg: primary,
228
+ selectedText: contrastText(primary),
229
+ count: primary,
230
+ status: {
231
+ draft: yellow,
232
+ approved: green,
233
+ changes: red,
234
+ review: primary,
235
+ none: brightBlack,
236
+ passing: green,
237
+ pending: yellow,
238
+ failing: red,
239
+ },
240
+ repos: {
241
+ opencode: primary,
242
+ "effect-smol": green,
243
+ "opencode-console": brightMagenta,
244
+ opencontrol: yellow,
245
+ default: blue,
246
+ },
247
+ diff: {
248
+ addedBg: mixHex(terminalBackground, green, diffAlpha),
249
+ removedBg: mixHex(terminalBackground, red, diffAlpha),
250
+ contextBg: panel,
251
+ lineNumberBg: borderSubtle,
252
+ addedLineNumberBg: mixHex(element, green, diffAlpha),
253
+ removedLineNumberBg: mixHex(element, red, diffAlpha),
254
+ },
255
+ }
256
+ }
257
+
258
+ const systemColors: ColorPalette = makeSystemColors()
259
+
102
260
  const tokyoNightColors: ColorPalette = {
103
261
  background: "#1a1b26",
104
262
  modalBackground: "#24283b",
@@ -607,6 +765,7 @@ const vesperColors: ColorPalette = {
607
765
  }
608
766
 
609
767
  export const themeDefinitions: readonly ThemeDefinition[] = [
768
+ { id: "system", name: "System", description: "Use the terminal foreground, background, and ANSI palette", colors: systemColors },
610
769
  { id: "ghui", name: "GHUI", description: "Warm parchment accents on a deep slate background", colors: ghuiColors },
611
770
  { id: "tokyo-night", name: "Tokyo Night", description: "Cool indigo surfaces with neon editor accents", colors: tokyoNightColors },
612
771
  { id: "catppuccin", name: "Catppuccin", description: "Mocha lavender, peach, and soft pastel contrast", colors: catppuccinColors },
@@ -623,7 +782,7 @@ export const themeDefinitions: readonly ThemeDefinition[] = [
623
782
  { id: "opencode", name: "OpenCode", description: "Charcoal panels with peach, violet, and blue highlights", colors: opencodeColors },
624
783
  ] as const
625
784
 
626
- let activeTheme = themeDefinitions[0]!
785
+ let activeTheme = themeDefinitions.find((theme) => theme.id === "ghui") ?? themeDefinitions[0]!
627
786
 
628
787
  export const colors: ColorPalette = { ...ghuiColors }
629
788
 
@@ -646,3 +805,10 @@ export const setActiveTheme = (id: ThemeId) => {
646
805
  activeTheme = getThemeDefinition(id)
647
806
  Object.assign(colors, activeTheme.colors)
648
807
  }
808
+
809
+ export const setSystemThemeColors = (terminalColors: TerminalThemeColors) => {
810
+ Object.assign(systemColors, makeSystemColors(terminalColors))
811
+ if (activeTheme.id === "system") {
812
+ Object.assign(colors, systemColors)
813
+ }
814
+ }
package/src/ui/diff.ts CHANGED
@@ -1,7 +1,17 @@
1
- import { parseColor, SyntaxStyle } from "@opentui/core"
1
+ import { parseColor, pathToFiletype, SyntaxStyle } from "@opentui/core"
2
+ import { Data, Schema } from "effect"
2
3
  import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
3
4
  import { colors } from "./colors.js"
4
5
 
6
+ export const DiffView = Schema.Literals(["unified", "split"])
7
+ export type DiffView = Schema.Schema.Type<typeof DiffView>
8
+
9
+ export const DiffWrapMode = Schema.Literals(["none", "word"])
10
+ export type DiffWrapMode = Schema.Schema.Type<typeof DiffWrapMode>
11
+
12
+ export const DiffCommentKind = Schema.Literals(["addition", "deletion", "context"])
13
+ export type DiffCommentKind = Schema.Schema.Type<typeof DiffCommentKind>
14
+
5
15
  export interface DiffFilePatch {
6
16
  readonly name: string
7
17
  readonly filetype: string | undefined
@@ -25,7 +35,7 @@ export interface DiffCommentAnchor {
25
35
  readonly path: string
26
36
  readonly line: number
27
37
  readonly side: DiffCommentSide
28
- readonly kind: "addition" | "deletion" | "context"
38
+ readonly kind: DiffCommentKind
29
39
  readonly renderLine: number
30
40
  readonly text: string
31
41
  }
@@ -35,10 +45,13 @@ export type StackedDiffCommentAnchor = DiffCommentAnchor & {
35
45
  readonly localRenderLine: number
36
46
  }
37
47
 
38
- export type PullRequestDiffState =
39
- | { readonly status: "loading" }
40
- | { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
41
- | { readonly status: "error"; readonly error: string }
48
+ export type PullRequestDiffState = Data.TaggedEnum<{
49
+ Loading: {}
50
+ Ready: { readonly patch: string; readonly files: readonly DiffFilePatch[] }
51
+ Error: { readonly error: string }
52
+ }>
53
+
54
+ export const PullRequestDiffState = Data.taggedEnum<PullRequestDiffState>()
42
55
 
43
56
  export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
44
57
  keyword: { fg: parseColor(colors.accent), bold: true },
@@ -60,55 +73,45 @@ export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
60
73
  default: { fg: parseColor(colors.text) },
61
74
  })
62
75
 
63
- const extensionFiletypes: Record<string, string> = {
64
- c: "c",
65
- cc: "cpp",
66
- cpp: "cpp",
67
- cs: "csharp",
68
- css: "css",
69
- go: "go",
70
- h: "c",
71
- hpp: "cpp",
72
- html: "html",
73
- java: "java",
74
- js: "javascript",
75
- jsx: "javascript",
76
- json: "json",
77
- kt: "kotlin",
78
- md: "markdown",
79
- mjs: "javascript",
80
- py: "python",
81
- rs: "rust",
82
- rb: "ruby",
83
- sh: "bash",
84
- svelte: "svelte",
85
- toml: "toml",
86
- ts: "typescript",
87
- tsx: "typescript",
88
- txt: "text",
89
- vue: "vue",
90
- yaml: "yaml",
91
- yml: "yaml",
92
- zig: "zig",
93
- }
76
+ const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
94
77
 
95
- const filetypeForPath = (path: string) => {
96
- const basename = path.split("/").at(-1) ?? path
97
- if (basename === "Dockerfile") return "dockerfile"
98
- const extension = basename.includes(".") ? basename.split(".").at(-1)?.toLowerCase() : undefined
99
- return extension ? extensionFiletypes[extension] : undefined
78
+ const readDiffPath = (value: string, start: number) => {
79
+ if (value[start] === '"') {
80
+ for (let index = start + 1; index < value.length; index++) {
81
+ if (value[index] === '"' && value[index - 1] !== "\\") {
82
+ const raw = value.slice(start, index + 1)
83
+ try {
84
+ return { path: JSON.parse(raw) as string, end: index + 1 }
85
+ } catch {
86
+ return { path: raw, end: index + 1 }
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ const end = value.slice(start).search(/\s/)
93
+ const pathEnd = end >= 0 ? start + end : value.length
94
+ return { path: value.slice(start, pathEnd), end: pathEnd }
100
95
  }
101
96
 
102
- const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
97
+ const parseDiffGitPaths = (line: string) => {
98
+ const prefix = "diff --git "
99
+ if (!line.startsWith(prefix)) return null
100
+ const left = readDiffPath(line, prefix.length)
101
+ const rightStart = line.slice(left.end).search(/\S/)
102
+ if (rightStart < 0) return null
103
+ const right = readDiffPath(line, left.end + rightStart)
104
+ return [left.path, right.path] as const
105
+ }
103
106
 
104
107
  const patchFileName = (patch: string) => {
105
108
  const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
106
109
  if (diffLine) {
107
- const match = diffLine.match(/^diff --git\s+(\S+)\s+(\S+)/)
108
- if (match) {
109
- const next = unquoteDiffPath(match[2]!)
110
+ const paths = parseDiffGitPaths(diffLine)
111
+ if (paths) {
112
+ const next = unquoteDiffPath(paths[1])
110
113
  if (next !== "/dev/null") return next
111
- return unquoteDiffPath(match[1]!)
114
+ return unquoteDiffPath(paths[0])
112
115
  }
113
116
  }
114
117
 
@@ -165,19 +168,19 @@ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
165
168
  const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
166
169
  const filePatch = normalizeHunkLineCounts(trimmed.slice(start, end).trimEnd())
167
170
  const name = patchFileName(filePatch)
168
- return { name, filetype: filetypeForPath(name), patch: filePatch }
171
+ return { name, filetype: pathToFiletype(name), patch: filePatch }
169
172
  })
170
173
  }
171
174
 
172
- export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
175
+ export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}:${pullRequest.headRefOid}`
173
176
 
174
177
  export const safeDiffFileIndex = (files: readonly DiffFilePatch[], index: number) =>
175
178
  files.length > 0 ? Math.max(0, Math.min(index, files.length - 1)) : 0
176
179
 
177
180
  export const buildStackedDiffFiles = (
178
181
  files: readonly DiffFilePatch[],
179
- view: "unified" | "split",
180
- wrapMode: "none" | "word",
182
+ view: DiffView,
183
+ wrapMode: DiffWrapMode,
181
184
  width: number,
182
185
  ): readonly StackedDiffFilePatch[] => {
183
186
  let offset = 0
@@ -197,14 +200,14 @@ export const buildStackedDiffFiles = (
197
200
  })
198
201
  }
199
202
 
203
+ export const stackedDiffFileAtLine = (stackedFiles: readonly StackedDiffFilePatch[], line: number) =>
204
+ stackedFiles.reduce<StackedDiffFilePatch | undefined>((current, file) => file.headerLine <= line ? file : current, undefined)
205
+
200
206
  export const diffStatText = (pullRequest: PullRequestItem) => {
201
207
  if (!pullRequest.detailLoaded) return "loading details"
202
208
  const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
203
- return [
204
- pullRequest.additions > 0 ? `+${pullRequest.additions}` : null,
205
- pullRequest.deletions > 0 ? `-${pullRequest.deletions}` : null,
206
- files,
207
- ].filter((part): part is string => part !== null).join(" ")
209
+ const stats = diffFileStatsText(pullRequest)
210
+ return stats ? `${stats} ${files}` : files
208
211
  }
209
212
 
210
213
  export const diffCommentLocationKey = (location: Pick<PullRequestReviewComment, "path" | "side" | "line">) => `${location.path}:${location.side}:${location.line}`
@@ -213,7 +216,7 @@ export const diffCommentAnchorKey = diffCommentLocationKey
213
216
 
214
217
  type PendingDiffCommentAnchor = Omit<DiffCommentAnchor, "renderLine">
215
218
 
216
- const diffContentWidth = (lines: readonly string[], view: "unified" | "split", width: number) => {
219
+ const diffContentWidth = (lines: readonly string[], view: DiffView, width: number) => {
217
220
  const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
218
221
  return view === "split"
219
222
  ? Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
@@ -242,14 +245,17 @@ export const diffFileStats = (file: DiffFilePatch): DiffFileStats => {
242
245
  }
243
246
 
244
247
  export const diffFileStatText = (file: DiffFilePatch) => {
245
- const stats = diffFileStats(file)
248
+ return diffFileStatsText(diffFileStats(file))
249
+ }
250
+
251
+ export const diffFileStatsText = (stats: DiffFileStats) => {
246
252
  return [
247
253
  stats.additions > 0 ? `+${stats.additions}` : null,
248
254
  stats.deletions > 0 ? `-${stats.deletions}` : null,
249
255
  ].filter((part): part is string => part !== null).join(" ")
250
256
  }
251
257
 
252
- export const getDiffCommentAnchors = (file: DiffFilePatch, view: "unified" | "split" = "unified", wrapMode: "none" | "word" = "none", width = 120): readonly DiffCommentAnchor[] => {
258
+ export const getDiffCommentAnchors = (file: DiffFilePatch, view: DiffView = "unified", wrapMode: DiffWrapMode = "none", width = 120): readonly DiffCommentAnchor[] => {
253
259
  const anchors: DiffCommentAnchor[] = []
254
260
  const lines = file.patch.split("\n")
255
261
  const contentWidth = diffContentWidth(lines, view, width)
@@ -330,8 +336,8 @@ export const getDiffCommentAnchors = (file: DiffFilePatch, view: "unified" | "sp
330
336
 
331
337
  export const getStackedDiffCommentAnchors = (
332
338
  stackedFiles: readonly StackedDiffFilePatch[],
333
- view: "unified" | "split" = "unified",
334
- wrapMode: "none" | "word" = "none",
339
+ view: DiffView = "unified",
340
+ wrapMode: DiffWrapMode = "none",
335
341
  width = 120,
336
342
  ): readonly StackedDiffCommentAnchor[] =>
337
343
  stackedFiles.flatMap((stackedFile) => getDiffCommentAnchors(stackedFile.file, view, wrapMode, width).map((anchor) => ({
@@ -354,7 +360,7 @@ export const scrollTopForVisibleLine = (currentTop: number, viewportHeight: numb
354
360
  return currentTop
355
361
  }
356
362
 
357
- const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
363
+ const estimatedWrappedLineCount = (text: string, width: number, wrapMode: DiffWrapMode) => {
358
364
  if (wrapMode === "none") return 1
359
365
  return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
360
366
  }
@@ -394,7 +400,7 @@ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
394
400
  return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
395
401
  }
396
402
 
397
- export const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
403
+ export const patchRenderableLineCount = (patch: string, view: DiffView, wrapMode: DiffWrapMode, width: number) => {
398
404
  const lines = patch.split("\n")
399
405
  const contentWidth = diffContentWidth(lines, view, width)
400
406
  let count = 0