@kitlangton/ghui 0.1.7 → 0.1.9
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/.env.example +4 -0
- package/package.json +1 -1
- package/src/App.tsx +219 -1417
- package/src/config.ts +17 -8
- package/src/domain.ts +17 -0
- package/src/mergeActions.ts +90 -0
- package/src/observability.ts +46 -0
- package/src/services/CommandRunner.ts +6 -1
- package/src/services/GitHubService.ts +154 -14
- package/src/ui/DetailsPane.tsx +498 -0
- package/src/ui/FooterHints.tsx +145 -0
- package/src/ui/PullRequestDiffPane.tsx +148 -0
- package/src/ui/PullRequestList.tsx +128 -0
- package/src/ui/colors.ts +28 -0
- package/src/ui/diff.ts +220 -0
- package/src/ui/modals.tsx +249 -0
- package/src/ui/primitives.tsx +111 -0
- package/src/ui/pullRequests.ts +72 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import { useMemo, type Ref } from "react"
|
|
3
|
+
import type { PullRequestItem } from "../domain.js"
|
|
4
|
+
import { colors } from "./colors.js"
|
|
5
|
+
import { diffStatText, diffSyntaxStyle, patchRenderableLineCount, type PullRequestDiffState } from "./diff.js"
|
|
6
|
+
import { LoadingPane, StatusCard } from "./DetailsPane.js"
|
|
7
|
+
import { Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
|
|
8
|
+
import { shortRepoName } from "./pullRequests.js"
|
|
9
|
+
|
|
10
|
+
const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
|
|
11
|
+
if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
|
|
12
|
+
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
13
|
+
type Part = { key: string; text: string; color: string }
|
|
14
|
+
const rawParts: Array<Part | null> = [
|
|
15
|
+
pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
|
|
16
|
+
pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
|
|
17
|
+
{ key: "files", text: files, color: colors.muted },
|
|
18
|
+
]
|
|
19
|
+
const parts = rawParts.filter((part): part is Part => part !== null)
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
{parts.map((part, index) => (
|
|
24
|
+
<span key={part.key} fg={part.color}>{`${index > 0 ? " " : ""}${part.text}`}</span>
|
|
25
|
+
))}
|
|
26
|
+
</>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const PullRequestDiffPane = ({
|
|
31
|
+
pullRequest,
|
|
32
|
+
diffState,
|
|
33
|
+
fileIndex,
|
|
34
|
+
view,
|
|
35
|
+
wrapMode,
|
|
36
|
+
paneWidth,
|
|
37
|
+
height,
|
|
38
|
+
loadingIndicator,
|
|
39
|
+
scrollRef,
|
|
40
|
+
}: {
|
|
41
|
+
pullRequest: PullRequestItem | null
|
|
42
|
+
diffState: PullRequestDiffState | undefined
|
|
43
|
+
fileIndex: number
|
|
44
|
+
view: "unified" | "split"
|
|
45
|
+
wrapMode: "none" | "word"
|
|
46
|
+
paneWidth: number
|
|
47
|
+
height: number
|
|
48
|
+
loadingIndicator: string
|
|
49
|
+
scrollRef: Ref<ScrollBoxRenderable>
|
|
50
|
+
}) => {
|
|
51
|
+
const readyFiles = diffState?.status === "ready" ? diffState.files : []
|
|
52
|
+
const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
|
|
53
|
+
const file = readyFiles[safeIndex] ?? null
|
|
54
|
+
const diffHeight = useMemo(
|
|
55
|
+
() => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
|
|
56
|
+
[file?.patch, view, wrapMode, paneWidth],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if (!pullRequest) {
|
|
60
|
+
return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const stats = diffStatText(pullRequest)
|
|
64
|
+
const headerWidth = Math.max(24, paneWidth - 2)
|
|
65
|
+
const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
|
|
66
|
+
const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
|
|
67
|
+
|
|
68
|
+
if (!diffState || diffState.status === "loading") {
|
|
69
|
+
return (
|
|
70
|
+
<box height={height} flexDirection="column">
|
|
71
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
72
|
+
<TextLine>
|
|
73
|
+
<span fg={colors.count}>#{pullRequest.number}</span>
|
|
74
|
+
<span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
|
|
75
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
76
|
+
<DiffStats pullRequest={pullRequest} />
|
|
77
|
+
</TextLine>
|
|
78
|
+
</box>
|
|
79
|
+
<Divider width={paneWidth} />
|
|
80
|
+
<LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
|
|
81
|
+
</box>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (diffState.status === "error") {
|
|
86
|
+
return (
|
|
87
|
+
<box height={height} flexDirection="column">
|
|
88
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
89
|
+
<PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
|
|
90
|
+
</box>
|
|
91
|
+
<Divider width={paneWidth} />
|
|
92
|
+
<StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
|
|
93
|
+
</box>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (readyFiles.length === 0 || !file) {
|
|
98
|
+
return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
|
|
102
|
+
const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<box height={height} flexDirection="column">
|
|
106
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
107
|
+
<TextLine>
|
|
108
|
+
<span fg={colors.count}>#{pullRequest.number}</span>
|
|
109
|
+
<span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
|
|
110
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
111
|
+
<DiffStats pullRequest={pullRequest} />
|
|
112
|
+
</TextLine>
|
|
113
|
+
</box>
|
|
114
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
115
|
+
<TextLine>
|
|
116
|
+
<span fg={colors.text}>{fitCell(file.name, fileNameWidth)}</span>
|
|
117
|
+
<span fg={colors.muted}> {fileCounter}</span>
|
|
118
|
+
</TextLine>
|
|
119
|
+
</box>
|
|
120
|
+
<Divider width={paneWidth} />
|
|
121
|
+
<scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false}>
|
|
122
|
+
<diff
|
|
123
|
+
key={`${pullRequest.url}-${safeIndex}-${view}-${wrapMode}`}
|
|
124
|
+
diff={file.patch}
|
|
125
|
+
view={view}
|
|
126
|
+
syncScroll
|
|
127
|
+
filetype={file.filetype ?? "text"}
|
|
128
|
+
syntaxStyle={diffSyntaxStyle}
|
|
129
|
+
showLineNumbers
|
|
130
|
+
wrapMode={wrapMode}
|
|
131
|
+
addedBg="#17351f"
|
|
132
|
+
removedBg="#3a1e22"
|
|
133
|
+
contextBg="transparent"
|
|
134
|
+
addedSignColor={colors.status.passing}
|
|
135
|
+
removedSignColor={colors.status.failing}
|
|
136
|
+
lineNumberFg={colors.muted}
|
|
137
|
+
lineNumberBg="#151515"
|
|
138
|
+
addedLineNumberBg="#12301a"
|
|
139
|
+
removedLineNumberBg="#35171b"
|
|
140
|
+
selectionBg={colors.selectedBg}
|
|
141
|
+
selectionFg={colors.selectedText}
|
|
142
|
+
height={diffHeight}
|
|
143
|
+
style={{ flexShrink: 0 }}
|
|
144
|
+
/>
|
|
145
|
+
</scrollbox>
|
|
146
|
+
</box>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import type { PullRequestItem } from "../domain.js"
|
|
3
|
+
import { daysOpen } from "../date.js"
|
|
4
|
+
import { colors } from "./colors.js"
|
|
5
|
+
import { fitCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
|
|
6
|
+
import { checkLabel, repoColor, reviewIcon, statusColor } from "./pullRequests.js"
|
|
7
|
+
|
|
8
|
+
export type LoadStatus = "loading" | "ready" | "error"
|
|
9
|
+
export type PullRequestGroups = Array<[string, PullRequestItem[]]>
|
|
10
|
+
|
|
11
|
+
const GROUP_ICON = "◆"
|
|
12
|
+
|
|
13
|
+
const getRowLayout = (contentWidth: number, numberWidth = 6) => {
|
|
14
|
+
const reviewWidth = 1
|
|
15
|
+
const checkWidth = 6
|
|
16
|
+
const ageWidth = 4
|
|
17
|
+
const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth
|
|
18
|
+
const titleWidth = Math.max(8, contentWidth - fixedWidth)
|
|
19
|
+
return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
|
|
23
|
+
if (pullRequests.length === 0) return 4
|
|
24
|
+
const maxLen = Math.max(...pullRequests.map((pr) => String(pr.number).length))
|
|
25
|
+
return maxLen + 1
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GroupTitle = ({ label, color }: { label: string; color: string }) => (
|
|
29
|
+
<TextLine>
|
|
30
|
+
<span fg={color}>{GROUP_ICON} </span>
|
|
31
|
+
<span fg={color} attributes={TextAttributes.BOLD}>{label}</span>
|
|
32
|
+
</TextLine>
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const PullRequestRow = ({
|
|
36
|
+
pullRequest,
|
|
37
|
+
selected,
|
|
38
|
+
contentWidth,
|
|
39
|
+
numWidth,
|
|
40
|
+
onSelect,
|
|
41
|
+
}: {
|
|
42
|
+
pullRequest: PullRequestItem
|
|
43
|
+
selected: boolean
|
|
44
|
+
contentWidth: number
|
|
45
|
+
numWidth: number
|
|
46
|
+
onSelect: () => void
|
|
47
|
+
}) => {
|
|
48
|
+
const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
|
|
49
|
+
const ageText = `${daysOpen(pullRequest.createdAt)}d`
|
|
50
|
+
const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
|
|
51
|
+
const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
|
|
52
|
+
const fillerWidth = Math.max(0, contentWidth - rowWidth)
|
|
53
|
+
const indicatorColor = pullRequest.autoMergeEnabled ? colors.accent : statusColor(pullRequest.reviewStatus)
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<box height={1} onMouseDown={onSelect}>
|
|
57
|
+
<TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
|
|
58
|
+
<span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
|
|
59
|
+
<span> </span>
|
|
60
|
+
<span fg={selected ? colors.accent : colors.count}>{fitCell(`#${pullRequest.number}`, numberWidth, "right")}</span>
|
|
61
|
+
<span> </span>
|
|
62
|
+
<span>{fitCell(pullRequest.title, titleWidth)}</span>
|
|
63
|
+
<span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
|
|
64
|
+
<span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
|
|
65
|
+
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
66
|
+
</TextLine>
|
|
67
|
+
</box>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const PullRequestList = ({
|
|
72
|
+
groups,
|
|
73
|
+
selectedUrl,
|
|
74
|
+
status,
|
|
75
|
+
error,
|
|
76
|
+
contentWidth,
|
|
77
|
+
filterText,
|
|
78
|
+
showFilterBar,
|
|
79
|
+
isFilterEditing,
|
|
80
|
+
onSelectPullRequest,
|
|
81
|
+
}: {
|
|
82
|
+
groups: PullRequestGroups
|
|
83
|
+
selectedUrl: string | null
|
|
84
|
+
status: LoadStatus
|
|
85
|
+
error: string | null
|
|
86
|
+
contentWidth: number
|
|
87
|
+
filterText: string
|
|
88
|
+
showFilterBar: boolean
|
|
89
|
+
isFilterEditing: boolean
|
|
90
|
+
onSelectPullRequest: (url: string) => void
|
|
91
|
+
}) => {
|
|
92
|
+
const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
|
|
93
|
+
const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<box flexDirection="column">
|
|
97
|
+
<SectionTitle title="PULL REQUESTS" />
|
|
98
|
+
{showFilterBar ? (
|
|
99
|
+
<TextLine>
|
|
100
|
+
<span fg={colors.count}>/</span>
|
|
101
|
+
<span fg={colors.muted}> </span>
|
|
102
|
+
<span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
|
|
103
|
+
</TextLine>
|
|
104
|
+
) : null}
|
|
105
|
+
{status === "loading" && itemCount === 0 ? <PlainLine text="- Loading pull requests..." fg={colors.muted} /> : null}
|
|
106
|
+
{status === "error" ? <PlainLine text={`- ${error ?? "Could not load pull requests."}`} fg={colors.error} /> : null}
|
|
107
|
+
{status === "ready" && itemCount === 0 ? <PlainLine text={emptyText} fg={colors.muted} /> : null}
|
|
108
|
+
{groups.map(([repo, pullRequests]) => {
|
|
109
|
+
const numWidth = groupNumberWidth(pullRequests)
|
|
110
|
+
return (
|
|
111
|
+
<box key={repo} flexDirection="column">
|
|
112
|
+
<GroupTitle label={repo} color={repoColor(repo)} />
|
|
113
|
+
{pullRequests.map((pullRequest) => (
|
|
114
|
+
<PullRequestRow
|
|
115
|
+
key={pullRequest.url}
|
|
116
|
+
pullRequest={pullRequest}
|
|
117
|
+
selected={pullRequest.url === selectedUrl}
|
|
118
|
+
contentWidth={contentWidth}
|
|
119
|
+
numWidth={numWidth}
|
|
120
|
+
onSelect={() => onSelectPullRequest(pullRequest.url)}
|
|
121
|
+
/>
|
|
122
|
+
))}
|
|
123
|
+
</box>
|
|
124
|
+
)
|
|
125
|
+
})}
|
|
126
|
+
</box>
|
|
127
|
+
)
|
|
128
|
+
}
|
package/src/ui/colors.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const colors = {
|
|
2
|
+
text: "#ede7da",
|
|
3
|
+
muted: "#9f9788",
|
|
4
|
+
separator: "#6f685d",
|
|
5
|
+
accent: "#f4a51c",
|
|
6
|
+
inlineCode: "#d7c5a1",
|
|
7
|
+
error: "#f97316",
|
|
8
|
+
selectedBg: "#1d2430",
|
|
9
|
+
selectedText: "#f8fafc",
|
|
10
|
+
count: "#d7c5a1",
|
|
11
|
+
status: {
|
|
12
|
+
draft: "#f59e0b",
|
|
13
|
+
approved: "#7dd3a3",
|
|
14
|
+
changes: "#f87171",
|
|
15
|
+
review: "#93c5fd",
|
|
16
|
+
none: "#9f9788",
|
|
17
|
+
passing: "#7dd3a3",
|
|
18
|
+
pending: "#f4a51c",
|
|
19
|
+
failing: "#f87171",
|
|
20
|
+
},
|
|
21
|
+
repos: {
|
|
22
|
+
opencode: "#60a5fa",
|
|
23
|
+
"effect-smol": "#34d399",
|
|
24
|
+
"opencode-console": "#f472b6",
|
|
25
|
+
opencontrol: "#f59e0b",
|
|
26
|
+
default: "#93c5fd",
|
|
27
|
+
},
|
|
28
|
+
} as const
|
package/src/ui/diff.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { parseColor, SyntaxStyle } from "@opentui/core"
|
|
2
|
+
import type { PullRequestItem } from "../domain.js"
|
|
3
|
+
import { colors } from "./colors.js"
|
|
4
|
+
|
|
5
|
+
export interface DiffFilePatch {
|
|
6
|
+
readonly name: string
|
|
7
|
+
readonly filetype: string | undefined
|
|
8
|
+
readonly patch: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type PullRequestDiffState =
|
|
12
|
+
| { readonly status: "loading" }
|
|
13
|
+
| { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
|
|
14
|
+
| { readonly status: "error"; readonly error: string }
|
|
15
|
+
|
|
16
|
+
export const diffSyntaxStyle = SyntaxStyle.fromStyles({
|
|
17
|
+
keyword: { fg: parseColor("#f4a51c"), bold: true },
|
|
18
|
+
"keyword.import": { fg: parseColor("#f4a51c"), bold: true },
|
|
19
|
+
string: { fg: parseColor("#d7c5a1") },
|
|
20
|
+
comment: { fg: parseColor(colors.muted), italic: true },
|
|
21
|
+
number: { fg: parseColor("#93c5fd") },
|
|
22
|
+
boolean: { fg: parseColor("#93c5fd") },
|
|
23
|
+
constant: { fg: parseColor("#93c5fd") },
|
|
24
|
+
function: { fg: parseColor("#7dd3a3") },
|
|
25
|
+
"function.call": { fg: parseColor("#7dd3a3") },
|
|
26
|
+
constructor: { fg: parseColor("#f59e0b") },
|
|
27
|
+
type: { fg: parseColor("#f59e0b") },
|
|
28
|
+
operator: { fg: parseColor("#f87171") },
|
|
29
|
+
variable: { fg: parseColor(colors.text) },
|
|
30
|
+
property: { fg: parseColor("#93c5fd") },
|
|
31
|
+
bracket: { fg: parseColor(colors.text) },
|
|
32
|
+
punctuation: { fg: parseColor(colors.text) },
|
|
33
|
+
default: { fg: parseColor(colors.text) },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const extensionFiletypes: Record<string, string> = {
|
|
37
|
+
c: "c",
|
|
38
|
+
cc: "cpp",
|
|
39
|
+
cpp: "cpp",
|
|
40
|
+
cs: "csharp",
|
|
41
|
+
css: "css",
|
|
42
|
+
go: "go",
|
|
43
|
+
h: "c",
|
|
44
|
+
hpp: "cpp",
|
|
45
|
+
html: "html",
|
|
46
|
+
java: "java",
|
|
47
|
+
js: "javascript",
|
|
48
|
+
jsx: "javascript",
|
|
49
|
+
json: "json",
|
|
50
|
+
kt: "kotlin",
|
|
51
|
+
md: "markdown",
|
|
52
|
+
mjs: "javascript",
|
|
53
|
+
py: "python",
|
|
54
|
+
rs: "rust",
|
|
55
|
+
rb: "ruby",
|
|
56
|
+
sh: "bash",
|
|
57
|
+
svelte: "svelte",
|
|
58
|
+
toml: "toml",
|
|
59
|
+
ts: "typescript",
|
|
60
|
+
tsx: "typescript",
|
|
61
|
+
txt: "text",
|
|
62
|
+
vue: "vue",
|
|
63
|
+
yaml: "yaml",
|
|
64
|
+
yml: "yaml",
|
|
65
|
+
zig: "zig",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const filetypeForPath = (path: string) => {
|
|
69
|
+
const basename = path.split("/").at(-1) ?? path
|
|
70
|
+
if (basename === "Dockerfile") return "dockerfile"
|
|
71
|
+
const extension = basename.includes(".") ? basename.split(".").at(-1)?.toLowerCase() : undefined
|
|
72
|
+
return extension ? extensionFiletypes[extension] : undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
|
|
76
|
+
|
|
77
|
+
const patchFileName = (patch: string) => {
|
|
78
|
+
const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
|
|
79
|
+
if (diffLine) {
|
|
80
|
+
const match = diffLine.match(/^diff --git\s+(\S+)\s+(\S+)/)
|
|
81
|
+
if (match) {
|
|
82
|
+
const next = unquoteDiffPath(match[2]!)
|
|
83
|
+
if (next !== "/dev/null") return next
|
|
84
|
+
return unquoteDiffPath(match[1]!)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const nextLine = patch.split("\n").find((line) => line.startsWith("+++ "))
|
|
89
|
+
return nextLine ? unquoteDiffPath(nextLine.slice(4).trim()) : "diff"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
|
|
93
|
+
const trimmed = patch.trimEnd()
|
|
94
|
+
if (trimmed.length === 0) return []
|
|
95
|
+
|
|
96
|
+
const matches = [...trimmed.matchAll(/^diff --git .+$/gm)]
|
|
97
|
+
if (matches.length === 0) {
|
|
98
|
+
return [{ name: "diff", filetype: undefined, patch: trimmed }]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return matches.map((match, index) => {
|
|
102
|
+
const start = match.index ?? 0
|
|
103
|
+
const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
|
|
104
|
+
const filePatch = trimmed.slice(start, end).trimEnd()
|
|
105
|
+
const name = patchFileName(filePatch)
|
|
106
|
+
return { name, filetype: filetypeForPath(name), patch: filePatch }
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
|
|
111
|
+
|
|
112
|
+
export const diffStatText = (pullRequest: PullRequestItem) => {
|
|
113
|
+
if (!pullRequest.detailLoaded) return "loading details"
|
|
114
|
+
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
115
|
+
return [
|
|
116
|
+
pullRequest.additions > 0 ? `+${pullRequest.additions}` : null,
|
|
117
|
+
pullRequest.deletions > 0 ? `-${pullRequest.deletions}` : null,
|
|
118
|
+
files,
|
|
119
|
+
].filter((part): part is string => part !== null).join(" ")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
|
|
123
|
+
if (wrapMode === "none") return 1
|
|
124
|
+
return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const patchLineNumberGutterWidth = (lines: readonly string[]) => {
|
|
128
|
+
let maxLineNumber = 1
|
|
129
|
+
let hasSigns = false
|
|
130
|
+
let oldLine = 0
|
|
131
|
+
let newLine = 0
|
|
132
|
+
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
|
|
135
|
+
if (hunk) {
|
|
136
|
+
oldLine = Number(hunk[1])
|
|
137
|
+
newLine = Number(hunk[2])
|
|
138
|
+
maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const firstChar = line[0]
|
|
143
|
+
if (firstChar === "-") {
|
|
144
|
+
hasSigns = true
|
|
145
|
+
maxLineNumber = Math.max(maxLineNumber, oldLine)
|
|
146
|
+
oldLine++
|
|
147
|
+
} else if (firstChar === "+") {
|
|
148
|
+
hasSigns = true
|
|
149
|
+
maxLineNumber = Math.max(maxLineNumber, newLine)
|
|
150
|
+
newLine++
|
|
151
|
+
} else if (firstChar === " ") {
|
|
152
|
+
maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
|
|
153
|
+
oldLine++
|
|
154
|
+
newLine++
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const digits = Math.floor(Math.log10(maxLineNumber)) + 1
|
|
159
|
+
return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
|
|
163
|
+
const lines = patch.split("\n")
|
|
164
|
+
const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
|
|
165
|
+
const splitPaneWidth = Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
|
|
166
|
+
const unifiedPaneWidth = Math.max(1, width - lineNumberGutterWidth)
|
|
167
|
+
const contentWidth = view === "split" ? splitPaneWidth : unifiedPaneWidth
|
|
168
|
+
let count = 0
|
|
169
|
+
let inHunk = false
|
|
170
|
+
let deletions: number[] = []
|
|
171
|
+
let additions: number[] = []
|
|
172
|
+
|
|
173
|
+
const flushChangeBlock = () => {
|
|
174
|
+
if (deletions.length === 0 && additions.length === 0) return
|
|
175
|
+
if (view === "split") {
|
|
176
|
+
const rows = Math.max(deletions.length, additions.length)
|
|
177
|
+
for (let index = 0; index < rows; index++) {
|
|
178
|
+
const deletionCount = index < deletions.length ? deletions[index]! : 1
|
|
179
|
+
const additionCount = index < additions.length ? additions[index]! : 1
|
|
180
|
+
count += Math.max(deletionCount, additionCount)
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
for (const deletion of deletions) count += deletion
|
|
184
|
+
for (const addition of additions) count += addition
|
|
185
|
+
}
|
|
186
|
+
deletions = []
|
|
187
|
+
additions = []
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
if (line.startsWith("@@")) {
|
|
192
|
+
flushChangeBlock()
|
|
193
|
+
inHunk = true
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!inHunk) continue
|
|
198
|
+
|
|
199
|
+
const firstChar = line[0]
|
|
200
|
+
if (firstChar === "\\") continue
|
|
201
|
+
|
|
202
|
+
if (firstChar === "-") {
|
|
203
|
+
deletions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (firstChar === "+") {
|
|
208
|
+
additions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (firstChar === " ") {
|
|
213
|
+
flushChangeBlock()
|
|
214
|
+
count += estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
flushChangeBlock()
|
|
219
|
+
return Math.max(1, count)
|
|
220
|
+
}
|