@kitlangton/ghui 0.1.19 → 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/bin/ghui.js +64 -1
- package/package.json +18 -7
- package/src/App.tsx +1075 -653
- package/src/appCommands.ts +330 -0
- package/src/commands.ts +73 -0
- package/src/config.ts +10 -0
- package/src/domain.ts +29 -20
- package/src/errors.ts +10 -0
- package/src/index.tsx +30 -3
- package/src/mergeActions.ts +1 -6
- package/src/pullRequestCache.ts +19 -0
- package/src/pullRequestViews.ts +45 -0
- package/src/services/BrowserOpener.ts +22 -0
- package/src/services/Clipboard.ts +46 -0
- package/src/services/CommandRunner.ts +14 -6
- package/src/services/GitHubService.ts +290 -126
- package/src/services/MockGitHubService.ts +146 -0
- package/src/ui/CommandPalette.tsx +156 -0
- package/src/ui/DetailsPane.tsx +49 -63
- package/src/ui/FooterHints.tsx +84 -179
- package/src/ui/PullRequestDiffPane.tsx +29 -50
- package/src/ui/PullRequestList.tsx +99 -45
- package/src/ui/colors.ts +167 -1
- package/src/ui/diff.ts +34 -44
- package/src/ui/diffStats.tsx +25 -0
- package/src/ui/modals.tsx +263 -295
- package/src/ui/primitives.tsx +90 -0
- package/src/ui/pullRequests.ts +44 -12
- package/src/ui/singleLineInput.ts +25 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect"
|
|
2
|
+
import type { CheckItem, CreatePullRequestCommentInput, Mergeable, PullRequestItem, PullRequestLabel, PullRequestMergeInfo, PullRequestPage, PullRequestQueueMode, PullRequestReviewComment, ReviewStatus } from "../domain.js"
|
|
3
|
+
import { GitHubService } from "./GitHubService.js"
|
|
4
|
+
|
|
5
|
+
export interface MockOptions {
|
|
6
|
+
readonly prCount: number
|
|
7
|
+
readonly repoCount?: number
|
|
8
|
+
readonly username?: string
|
|
9
|
+
readonly seed?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const REVIEW_CYCLE: readonly ReviewStatus[] = ["approved", "changes", "review", "none", "draft"]
|
|
13
|
+
const MERGEABLE_CYCLE: readonly Mergeable[] = ["mergeable", "conflicting", "unknown"]
|
|
14
|
+
|
|
15
|
+
const synthCheckSummary = (passed: number, total: number): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
|
|
16
|
+
const checks: readonly CheckItem[] = Array.from({ length: total }, (_, index) => ({
|
|
17
|
+
name: `check-${index}`,
|
|
18
|
+
status: "completed",
|
|
19
|
+
conclusion: index < passed ? "success" : "failure",
|
|
20
|
+
}))
|
|
21
|
+
if (total === 0) return { checkStatus: "none", checkSummary: null, checks: [] }
|
|
22
|
+
if (passed === total) return { checkStatus: "passing", checkSummary: `${passed}/${total}`, checks }
|
|
23
|
+
return { checkStatus: "failing", checkSummary: `${passed}/${total}`, checks }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const synthLabels = (index: number): readonly PullRequestLabel[] => {
|
|
27
|
+
if (index % 5 === 0) return [{ name: "bug", color: "#d73a4a" }]
|
|
28
|
+
if (index % 7 === 0) return [{ name: "enhancement", color: "#a2eeef" }, { name: "tests", color: "#0e8a16" }]
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const buildPullRequest = (index: number, options: Required<MockOptions>): PullRequestItem => {
|
|
33
|
+
const repoIndex = index % options.repoCount
|
|
34
|
+
const repository = `mock-org/repo-${repoIndex}`
|
|
35
|
+
const number = 1000 + index
|
|
36
|
+
const total = 8 + (index % 5)
|
|
37
|
+
const passed = total - (index % 3 === 0 ? 1 : 0)
|
|
38
|
+
const review = REVIEW_CYCLE[index % REVIEW_CYCLE.length]!
|
|
39
|
+
const createdAt = new Date(Date.now() - index * 86_400_000)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
repository,
|
|
43
|
+
author: options.username,
|
|
44
|
+
headRefOid: `deadbeef${index.toString(16).padStart(8, "0")}`,
|
|
45
|
+
number,
|
|
46
|
+
title: `Mock PR ${number}: example change ${index}`,
|
|
47
|
+
body: `This is mock pull request #${number}.\n\nLine A.\nLine B.`,
|
|
48
|
+
labels: synthLabels(index),
|
|
49
|
+
additions: 10 + index,
|
|
50
|
+
deletions: 5 + (index % 11),
|
|
51
|
+
changedFiles: 1 + (index % 7),
|
|
52
|
+
state: "open",
|
|
53
|
+
reviewStatus: review,
|
|
54
|
+
...synthCheckSummary(passed, total),
|
|
55
|
+
autoMergeEnabled: index % 11 === 0,
|
|
56
|
+
detailLoaded: true,
|
|
57
|
+
createdAt,
|
|
58
|
+
closedAt: null,
|
|
59
|
+
url: `https://github.com/${repository}/pull/${number}`,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const buildMockPullRequests = (options: MockOptions): readonly PullRequestItem[] => {
|
|
64
|
+
const resolved: Required<MockOptions> = {
|
|
65
|
+
prCount: options.prCount,
|
|
66
|
+
repoCount: options.repoCount ?? 4,
|
|
67
|
+
username: options.username ?? "mock-user",
|
|
68
|
+
seed: options.seed ?? 0,
|
|
69
|
+
}
|
|
70
|
+
return Array.from({ length: resolved.prCount }, (_, index) => buildPullRequest(index, resolved))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const filterByView = (mode: PullRequestQueueMode, repository: string | null, source: readonly PullRequestItem[]) => {
|
|
74
|
+
if (mode === "repository") return repository ? source.filter((item) => item.repository === repository) : []
|
|
75
|
+
return source
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const pageItems = (source: readonly PullRequestItem[], cursor: string | null, pageSize: number): PullRequestPage => {
|
|
79
|
+
const start = cursor ? Number.parseInt(cursor, 10) : 0
|
|
80
|
+
const safeStart = Number.isFinite(start) && start >= 0 ? start : 0
|
|
81
|
+
const safePageSize = Math.max(1, Math.min(100, pageSize))
|
|
82
|
+
const end = Math.min(source.length, safeStart + safePageSize)
|
|
83
|
+
return {
|
|
84
|
+
items: source.slice(safeStart, end),
|
|
85
|
+
endCursor: end > safeStart ? String(end) : null,
|
|
86
|
+
hasNextPage: end < source.length,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const MockGitHubService = {
|
|
91
|
+
layer: (options: MockOptions) => {
|
|
92
|
+
const items = buildMockPullRequests(options)
|
|
93
|
+
const username = options.username ?? "mock-user"
|
|
94
|
+
const summaryItems = items.map((item) => ({
|
|
95
|
+
...item,
|
|
96
|
+
body: "",
|
|
97
|
+
labels: [],
|
|
98
|
+
additions: 0,
|
|
99
|
+
deletions: 0,
|
|
100
|
+
changedFiles: 0,
|
|
101
|
+
detailLoaded: false,
|
|
102
|
+
} satisfies PullRequestItem))
|
|
103
|
+
const findPullRequest = (repository: string, number: number) => items.find((item) => item.repository === repository && item.number === number) ?? items[0]!
|
|
104
|
+
|
|
105
|
+
return Layer.succeed(
|
|
106
|
+
GitHubService,
|
|
107
|
+
GitHubService.of({
|
|
108
|
+
listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, summaryItems)),
|
|
109
|
+
listOpenPullRequestPage: (input) => Effect.succeed(pageItems(filterByView(input.mode, input.repository, summaryItems), input.cursor, input.pageSize)),
|
|
110
|
+
listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, items)),
|
|
111
|
+
getPullRequestDetails: (repository, number) => Effect.succeed(findPullRequest(repository, number)),
|
|
112
|
+
getAuthenticatedUser: () => Effect.succeed(username),
|
|
113
|
+
getPullRequestDiff: (_repo, _number) => Effect.succeed(""),
|
|
114
|
+
listPullRequestComments: (_repo, _number) => Effect.succeed([] as readonly PullRequestReviewComment[]),
|
|
115
|
+
getPullRequestMergeInfo: (repository, number) => Effect.succeed({
|
|
116
|
+
repository,
|
|
117
|
+
number,
|
|
118
|
+
title: `Mock PR ${number}`,
|
|
119
|
+
state: "open",
|
|
120
|
+
isDraft: false,
|
|
121
|
+
mergeable: MERGEABLE_CYCLE[number % MERGEABLE_CYCLE.length]!,
|
|
122
|
+
reviewStatus: "approved",
|
|
123
|
+
checkStatus: "passing",
|
|
124
|
+
checkSummary: "10/10",
|
|
125
|
+
autoMergeEnabled: false,
|
|
126
|
+
} satisfies PullRequestMergeInfo),
|
|
127
|
+
mergePullRequest: () => Effect.void,
|
|
128
|
+
closePullRequest: () => Effect.void,
|
|
129
|
+
createPullRequestComment: (input: CreatePullRequestCommentInput) => Effect.succeed({
|
|
130
|
+
id: `mock:${Date.now()}`,
|
|
131
|
+
path: input.path,
|
|
132
|
+
line: input.line,
|
|
133
|
+
side: input.side,
|
|
134
|
+
author: username,
|
|
135
|
+
body: input.body,
|
|
136
|
+
createdAt: new Date(),
|
|
137
|
+
url: null,
|
|
138
|
+
} satisfies PullRequestReviewComment),
|
|
139
|
+
toggleDraftStatus: () => Effect.void,
|
|
140
|
+
listRepoLabels: () => Effect.succeed([]),
|
|
141
|
+
addPullRequestLabel: () => Effect.void,
|
|
142
|
+
removePullRequestLabel: () => Effect.void,
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
},
|
|
146
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import { useEffect, useMemo, useState } from "react"
|
|
3
|
+
import type { AppCommand } from "../commands.js"
|
|
4
|
+
import { clampCommandIndex } from "../commands.js"
|
|
5
|
+
import { colors } from "./colors.js"
|
|
6
|
+
import { scrollTopForVisibleLine } from "./diff.js"
|
|
7
|
+
import { centerCell, Filler, fitCell, HintRow, PlainLine, StandardModal, standardModalDims, TextLine, trimCell } from "./primitives.js"
|
|
8
|
+
|
|
9
|
+
const scopeLabels = {
|
|
10
|
+
Global: "App",
|
|
11
|
+
View: "View",
|
|
12
|
+
"Pull request": "Pull Request",
|
|
13
|
+
Diff: "Diff",
|
|
14
|
+
Navigation: "Navigation",
|
|
15
|
+
System: "System",
|
|
16
|
+
} as const satisfies Record<AppCommand["scope"], string>
|
|
17
|
+
|
|
18
|
+
export type CommandPaletteRow =
|
|
19
|
+
| { readonly _tag: "section"; readonly scope: AppCommand["scope"] }
|
|
20
|
+
| { readonly _tag: "spacer" }
|
|
21
|
+
| { readonly _tag: "command"; readonly command: AppCommand; readonly commandIndex: number }
|
|
22
|
+
|
|
23
|
+
export const buildCommandPaletteRows = (commands: readonly AppCommand[]): readonly CommandPaletteRow[] => {
|
|
24
|
+
const rows: CommandPaletteRow[] = []
|
|
25
|
+
let previousScope: AppCommand["scope"] | null = null
|
|
26
|
+
for (let commandIndex = 0; commandIndex < commands.length; commandIndex++) {
|
|
27
|
+
const command = commands[commandIndex]!
|
|
28
|
+
if (command.scope !== previousScope) {
|
|
29
|
+
if (previousScope !== null) rows.push({ _tag: "spacer" })
|
|
30
|
+
rows.push({ _tag: "section", scope: command.scope })
|
|
31
|
+
previousScope = command.scope
|
|
32
|
+
}
|
|
33
|
+
rows.push({ _tag: "command", command, commandIndex })
|
|
34
|
+
}
|
|
35
|
+
return rows
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const commandPaletteSelectedRowIndex = (rows: readonly CommandPaletteRow[], selectedCommandIndex: number) =>
|
|
39
|
+
Math.max(0, rows.findIndex((row) => row._tag === "command" && row.commandIndex === selectedCommandIndex))
|
|
40
|
+
|
|
41
|
+
export const commandPaletteScrollTop = ({
|
|
42
|
+
current,
|
|
43
|
+
rowsLength,
|
|
44
|
+
listHeight,
|
|
45
|
+
selectedRowIndex,
|
|
46
|
+
}: {
|
|
47
|
+
readonly current: number
|
|
48
|
+
readonly rowsLength: number
|
|
49
|
+
readonly listHeight: number
|
|
50
|
+
readonly selectedRowIndex: number
|
|
51
|
+
}) => {
|
|
52
|
+
if (rowsLength <= listHeight) return 0
|
|
53
|
+
const maxScrollTop = Math.max(0, rowsLength - listHeight)
|
|
54
|
+
const clampScrollTop = (value: number) => Math.max(0, Math.min(value, maxScrollTop))
|
|
55
|
+
return clampScrollTop(scrollTopForVisibleLine(current, listHeight, selectedRowIndex, 0))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const CommandPalette = ({
|
|
59
|
+
commands,
|
|
60
|
+
query,
|
|
61
|
+
selectedIndex,
|
|
62
|
+
modalWidth,
|
|
63
|
+
modalHeight,
|
|
64
|
+
offsetLeft,
|
|
65
|
+
offsetTop,
|
|
66
|
+
}: {
|
|
67
|
+
commands: readonly AppCommand[]
|
|
68
|
+
query: string
|
|
69
|
+
selectedIndex: number
|
|
70
|
+
modalWidth: number
|
|
71
|
+
modalHeight: number
|
|
72
|
+
offsetLeft: number
|
|
73
|
+
offsetTop: number
|
|
74
|
+
}) => {
|
|
75
|
+
const { contentWidth, bodyHeight: listHeight, rowWidth } = standardModalDims(modalWidth, modalHeight)
|
|
76
|
+
const clampedIndex = clampCommandIndex(selectedIndex, commands)
|
|
77
|
+
const [scrollTop, setScrollTop] = useState(0)
|
|
78
|
+
const rows = useMemo(() => buildCommandPaletteRows(commands), [commands])
|
|
79
|
+
const selectedRowIndex = commandPaletteSelectedRowIndex(rows, clampedIndex)
|
|
80
|
+
const visibleRows = rows.slice(scrollTop, scrollTop + listHeight)
|
|
81
|
+
const bottomPaddingRows = Math.max(0, listHeight - visibleRows.length)
|
|
82
|
+
const countText = commands.length === 1 ? "1 command" : `${commands.length} commands`
|
|
83
|
+
const queryText = query.length > 0 ? query : "type a command, state, or shortcut"
|
|
84
|
+
const queryWidth = Math.max(1, contentWidth - 2)
|
|
85
|
+
const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
|
|
86
|
+
const emptyBottomRows = Math.max(0, listHeight - emptyTopRows - 1)
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
setScrollTop((current) => commandPaletteScrollTop({ current, rowsLength: rows.length, listHeight, selectedRowIndex }))
|
|
89
|
+
}, [listHeight, rows.length, selectedRowIndex])
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<StandardModal
|
|
93
|
+
left={offsetLeft}
|
|
94
|
+
top={offsetTop}
|
|
95
|
+
width={modalWidth}
|
|
96
|
+
height={modalHeight}
|
|
97
|
+
title="Command Palette"
|
|
98
|
+
headerRight={{ text: countText }}
|
|
99
|
+
subtitle={
|
|
100
|
+
<TextLine>
|
|
101
|
+
<span fg={colors.count}>› </span>
|
|
102
|
+
<span fg={query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
|
|
103
|
+
</TextLine>
|
|
104
|
+
}
|
|
105
|
+
footer={<HintRow items={[{ key: "↑↓", label: "select" }, { key: "enter", label: "run" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "close" }]} />}
|
|
106
|
+
>
|
|
107
|
+
{rows.length === 0 ? (
|
|
108
|
+
<>
|
|
109
|
+
<Filler rows={emptyTopRows} prefix="top" />
|
|
110
|
+
<PlainLine text={centerCell("No matching command", rowWidth)} fg={colors.muted} />
|
|
111
|
+
<Filler rows={emptyBottomRows} prefix="bottom" />
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
{visibleRows.map((row, index) => {
|
|
116
|
+
const rowIndex = scrollTop + index
|
|
117
|
+
if (row._tag === "spacer") {
|
|
118
|
+
return <PlainLine key={`spacer-${rowIndex}`} text="" />
|
|
119
|
+
}
|
|
120
|
+
if (row._tag === "section") {
|
|
121
|
+
return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { command, commandIndex } = row
|
|
125
|
+
const isSelected = commandIndex === clampedIndex
|
|
126
|
+
const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
|
|
127
|
+
const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
|
|
128
|
+
const trailingPadding = shortcut.length === 0 ? 0 : 1
|
|
129
|
+
// Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
|
|
130
|
+
const SELECTOR_WIDTH = 2
|
|
131
|
+
const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
|
|
132
|
+
const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
|
|
133
|
+
const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
|
|
134
|
+
const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
|
|
135
|
+
const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<box key={command.id} height={1}>
|
|
139
|
+
<TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
140
|
+
<span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
|
|
141
|
+
<span> </span>
|
|
142
|
+
{isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
|
|
143
|
+
{subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
|
|
144
|
+
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
145
|
+
{shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
|
|
146
|
+
{trailingPadding > 0 ? <span> </span> : null}
|
|
147
|
+
</TextLine>
|
|
148
|
+
</box>
|
|
149
|
+
)
|
|
150
|
+
})}
|
|
151
|
+
<Filler rows={bottomPaddingRows} prefix="pad" />
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
</StandardModal>
|
|
155
|
+
)
|
|
156
|
+
}
|
package/src/ui/DetailsPane.tsx
CHANGED
|
@@ -4,7 +4,8 @@ import { formatRelativeDate } from "../date.js"
|
|
|
4
4
|
import type { CheckItem, PullRequestItem } from "../domain.js"
|
|
5
5
|
import { colors, type ThemeId } from "./colors.js"
|
|
6
6
|
import { diffStatText } from "./diff.js"
|
|
7
|
-
import {
|
|
7
|
+
import { DiffStats } from "./diffStats.js"
|
|
8
|
+
import { centerCell, Divider, Filler, fitCell, PaddedRow, PlainLine, TextLine } from "./primitives.js"
|
|
8
9
|
import { labelColor, labelTextColor, reviewLabel, shortRepoName, statusColor } from "./pullRequests.js"
|
|
9
10
|
|
|
10
11
|
interface PreviewLine {
|
|
@@ -22,6 +23,7 @@ export interface DetailPlaceholderContent {
|
|
|
22
23
|
|
|
23
24
|
export const DETAIL_BODY_LINES = 6
|
|
24
25
|
export const DETAIL_PLACEHOLDER_ROWS = 4
|
|
26
|
+
export const DETAIL_BODY_SCROLL_LIMIT = 1_000
|
|
25
27
|
|
|
26
28
|
const pullRequestReferencePattern = /(#[0-9]+)/g
|
|
27
29
|
|
|
@@ -158,31 +160,6 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
|
|
|
158
160
|
return preview.slice(0, limit)
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
const BlankRow = () => <box height={1} />
|
|
162
|
-
|
|
163
|
-
const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
|
|
164
|
-
if (!pullRequest.detailLoaded) return <span fg={colors.muted}>loading details</span>
|
|
165
|
-
const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
|
|
166
|
-
type Part = { key: string; text: string; color: string }
|
|
167
|
-
const rawParts: Array<Part | null> = [
|
|
168
|
-
pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
|
|
169
|
-
pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
|
|
170
|
-
{ key: "files", text: files, color: colors.muted },
|
|
171
|
-
]
|
|
172
|
-
const parts = rawParts.filter((part): part is Part => part !== null)
|
|
173
|
-
|
|
174
|
-
return (
|
|
175
|
-
<>
|
|
176
|
-
{parts.map((part, index) => (
|
|
177
|
-
<Fragment key={part.key}>
|
|
178
|
-
{index > 0 ? <span fg={colors.muted}> </span> : null}
|
|
179
|
-
<span fg={part.color}>{part.text}</span>
|
|
180
|
-
</Fragment>
|
|
181
|
-
))}
|
|
182
|
-
</>
|
|
183
|
-
)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
163
|
const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
|
|
187
164
|
const seen = new Map<string, CheckItem>()
|
|
188
165
|
for (const check of checks) {
|
|
@@ -194,26 +171,30 @@ const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
|
|
|
194
171
|
return [...seen.values()]
|
|
195
172
|
}
|
|
196
173
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
174
|
+
type CheckKind = "passing" | "failing" | "in-progress" | "queued" | "missing"
|
|
175
|
+
|
|
176
|
+
const CHECK_DISPLAY: Record<CheckKind, { icon: string; color: string }> = {
|
|
177
|
+
passing: { icon: "✓", color: colors.status.passing },
|
|
178
|
+
failing: { icon: "✗", color: colors.status.failing },
|
|
179
|
+
"in-progress": { icon: "●", color: colors.status.pending },
|
|
180
|
+
queued: { icon: "○", color: colors.muted },
|
|
181
|
+
missing: { icon: "·", color: colors.muted },
|
|
205
182
|
}
|
|
206
183
|
|
|
207
|
-
const
|
|
184
|
+
const checkKind = (check: CheckItem): CheckKind => {
|
|
208
185
|
if (check.status === "completed") {
|
|
209
|
-
if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return
|
|
210
|
-
if (check.conclusion === "failure") return
|
|
211
|
-
return
|
|
186
|
+
if (check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped") return "passing"
|
|
187
|
+
if (check.conclusion === "failure") return "failing"
|
|
188
|
+
return "missing"
|
|
212
189
|
}
|
|
213
|
-
if (check.status === "in_progress") return
|
|
214
|
-
return
|
|
190
|
+
if (check.status === "in_progress") return "in-progress"
|
|
191
|
+
return "queued"
|
|
215
192
|
}
|
|
216
193
|
|
|
194
|
+
const checkIcon = (check: CheckItem) => CHECK_DISPLAY[checkKind(check)].icon
|
|
195
|
+
|
|
196
|
+
const checkColor = (check: CheckItem) => CHECK_DISPLAY[checkKind(check)].color
|
|
197
|
+
|
|
217
198
|
const checksRowCount = (checks: readonly CheckItem[]) => {
|
|
218
199
|
const unique = deduplicateChecks(checks)
|
|
219
200
|
return Math.ceil(unique.length / 2)
|
|
@@ -280,6 +261,10 @@ export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, content
|
|
|
280
261
|
return bodyPreview(pullRequest.body, contentWidth, bodyLines).length
|
|
281
262
|
}
|
|
282
263
|
|
|
264
|
+
export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number) => {
|
|
265
|
+
return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT)
|
|
266
|
+
}
|
|
267
|
+
|
|
283
268
|
export const getDetailsPaneHeight = ({
|
|
284
269
|
pullRequest,
|
|
285
270
|
contentWidth,
|
|
@@ -316,11 +301,10 @@ export const DetailHeader = ({
|
|
|
316
301
|
const statsText = diffStatText(pullRequest)
|
|
317
302
|
const labelsWidth = !pullRequest.detailLoaded
|
|
318
303
|
? "loading details...".length
|
|
319
|
-
: labels.length > 0
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
const statsGap = Math.max(2, contentWidth - labelsWidth - statsText.length)
|
|
304
|
+
: labels.reduce((total, label, index) => total + label.name.length + 2 + (index > 0 ? 1 : 0), 0)
|
|
305
|
+
const hasLabelContent = labelsWidth > 0
|
|
306
|
+
const showStats = contentWidth - labelsWidth - statsText.length >= (hasLabelContent ? 2 : 0)
|
|
307
|
+
const statsGap = Math.max(hasLabelContent ? 2 : 0, contentWidth - labelsWidth - statsText.length)
|
|
324
308
|
const opened = formatRelativeDate(pullRequest.createdAt)
|
|
325
309
|
const repo = shortRepoName(pullRequest.repository)
|
|
326
310
|
const author = viewerUsername && pullRequest.author !== viewerUsername ? ` by ${pullRequest.author}` : ""
|
|
@@ -334,7 +318,7 @@ export const DetailHeader = ({
|
|
|
334
318
|
|
|
335
319
|
return (
|
|
336
320
|
<>
|
|
337
|
-
<
|
|
321
|
+
<PaddedRow>
|
|
338
322
|
<TextLine>
|
|
339
323
|
<span fg={colors.count}>#{number}</span>
|
|
340
324
|
<span fg={colors.muted}> {repo}</span>
|
|
@@ -346,28 +330,28 @@ export const DetailHeader = ({
|
|
|
346
330
|
{statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
|
|
347
331
|
<span fg={colors.muted}>{opened}</span>
|
|
348
332
|
</TextLine>
|
|
349
|
-
</
|
|
333
|
+
</PaddedRow>
|
|
350
334
|
<box height={wrappedTitle.length} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
351
335
|
{wrappedTitle.map((line, index) => (
|
|
352
336
|
<PlainLine key={index} text={line} bold />
|
|
353
337
|
))}
|
|
354
338
|
</box>
|
|
355
|
-
<
|
|
339
|
+
<PaddedRow>
|
|
356
340
|
<TextLine>
|
|
357
341
|
{!pullRequest.detailLoaded ? <span fg={colors.muted}>loading details...</span> : labels.length > 0 ? labels.map((label, index) => (
|
|
358
342
|
<Fragment key={label.name}>
|
|
359
343
|
{index > 0 ? <span fg={colors.muted}> </span> : null}
|
|
360
344
|
<span bg={labelColor(label)} fg={labelTextColor(labelColor(label))}> {label.name} </span>
|
|
361
345
|
</Fragment>
|
|
362
|
-
)) :
|
|
346
|
+
)) : null}
|
|
363
347
|
{showStats ? (
|
|
364
348
|
<>
|
|
365
|
-
<span fg={colors.muted}>{" ".repeat(statsGap)}</span>
|
|
349
|
+
{statsGap > 0 ? <span fg={colors.muted}>{" ".repeat(statsGap)}</span> : null}
|
|
366
350
|
<DiffStats pullRequest={pullRequest} />
|
|
367
351
|
</>
|
|
368
352
|
) : null}
|
|
369
353
|
</TextLine>
|
|
370
|
-
</
|
|
354
|
+
</PaddedRow>
|
|
371
355
|
<box height={1}><Divider width={paneWidth} /></box>
|
|
372
356
|
{showChecks && unique.length > 0 ? (
|
|
373
357
|
<>
|
|
@@ -385,18 +369,20 @@ export const DetailBody = ({
|
|
|
385
369
|
pullRequest,
|
|
386
370
|
contentWidth,
|
|
387
371
|
bodyLines = DETAIL_BODY_LINES,
|
|
372
|
+
bodyLineLimit = bodyLines,
|
|
388
373
|
loadingIndicator,
|
|
389
374
|
themeId,
|
|
390
375
|
}: {
|
|
391
376
|
pullRequest: PullRequestItem
|
|
392
377
|
contentWidth: number
|
|
393
378
|
bodyLines?: number
|
|
379
|
+
bodyLineLimit?: number
|
|
394
380
|
loadingIndicator: string
|
|
395
381
|
themeId: ThemeId
|
|
396
382
|
}) => {
|
|
397
383
|
const previewLines = useMemo(
|
|
398
|
-
() => bodyPreview(pullRequest.body, contentWidth,
|
|
399
|
-
[pullRequest.body, contentWidth,
|
|
384
|
+
() => bodyPreview(pullRequest.body, contentWidth, bodyLineLimit),
|
|
385
|
+
[pullRequest.body, contentWidth, bodyLineLimit, themeId],
|
|
400
386
|
)
|
|
401
387
|
|
|
402
388
|
if (!pullRequest.detailLoaded) {
|
|
@@ -404,15 +390,15 @@ export const DetailBody = ({
|
|
|
404
390
|
const bottomRows = Math.max(0, bodyLines - topRows - 1)
|
|
405
391
|
return (
|
|
406
392
|
<box flexDirection="column" paddingLeft={1} paddingRight={1} height={bodyLines}>
|
|
407
|
-
{
|
|
393
|
+
<Filler rows={topRows} prefix="top" />
|
|
408
394
|
<PlainLine text={centerCell(`${loadingIndicator} Loading pull request details`, contentWidth)} fg={colors.muted} />
|
|
409
|
-
{
|
|
395
|
+
<Filler rows={bottomRows} prefix="bottom" />
|
|
410
396
|
</box>
|
|
411
397
|
)
|
|
412
398
|
}
|
|
413
399
|
|
|
414
400
|
return (
|
|
415
|
-
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
401
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1} height={previewLines.length}>
|
|
416
402
|
{previewLines.map((line, index) => (
|
|
417
403
|
<TextLine key={`${pullRequest.url}-${index}`}>
|
|
418
404
|
{line.segments.map((segment, segmentIndex) => (
|
|
@@ -472,9 +458,9 @@ export const LoadingPane = ({ content, width, height }: { content: DetailPlaceho
|
|
|
472
458
|
|
|
473
459
|
return (
|
|
474
460
|
<box height={height} flexDirection="column">
|
|
475
|
-
{
|
|
461
|
+
<Filler rows={topRows} prefix="top" />
|
|
476
462
|
<StatusCard content={content} width={width} />
|
|
477
|
-
{
|
|
463
|
+
<Filler rows={bottomRows} prefix="bottom" />
|
|
478
464
|
</box>
|
|
479
465
|
)
|
|
480
466
|
}
|
|
@@ -484,6 +470,7 @@ export const DetailsPane = ({
|
|
|
484
470
|
viewerUsername,
|
|
485
471
|
contentWidth,
|
|
486
472
|
bodyLines = DETAIL_BODY_LINES,
|
|
473
|
+
bodyLineLimit = bodyLines,
|
|
487
474
|
paneWidth = contentWidth + 2,
|
|
488
475
|
showChecks = false,
|
|
489
476
|
placeholderContent,
|
|
@@ -494,28 +481,27 @@ export const DetailsPane = ({
|
|
|
494
481
|
viewerUsername: string | null
|
|
495
482
|
contentWidth: number
|
|
496
483
|
bodyLines?: number
|
|
484
|
+
bodyLineLimit?: number
|
|
497
485
|
paneWidth?: number
|
|
498
486
|
showChecks?: boolean
|
|
499
487
|
placeholderContent: DetailPlaceholderContent
|
|
500
488
|
loadingIndicator: string
|
|
501
489
|
themeId: ThemeId
|
|
502
490
|
}) => {
|
|
503
|
-
const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines, paneWidth, showChecks })
|
|
491
|
+
const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks })
|
|
504
492
|
|
|
505
493
|
return (
|
|
506
494
|
<box flexDirection="column" height={contentHeight}>
|
|
507
495
|
{pullRequest ? (
|
|
508
496
|
<>
|
|
509
497
|
<DetailHeader pullRequest={pullRequest} viewerUsername={viewerUsername} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
|
|
510
|
-
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} loadingIndicator={loadingIndicator} themeId={themeId} />
|
|
498
|
+
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} bodyLineLimit={bodyLineLimit} loadingIndicator={loadingIndicator} themeId={themeId} />
|
|
511
499
|
</>
|
|
512
500
|
) : (
|
|
513
501
|
<>
|
|
514
502
|
<DetailPlaceholder content={placeholderContent} paneWidth={paneWidth} />
|
|
515
503
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
516
|
-
{
|
|
517
|
-
<BlankRow key={index} />
|
|
518
|
-
))}
|
|
504
|
+
<Filler rows={bodyLines} prefix="empty" />
|
|
519
505
|
</box>
|
|
520
506
|
</>
|
|
521
507
|
)}
|