@kitlangton/ghui 0.1.22 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/App.tsx +381 -357
- package/src/appCommands.ts +16 -4
- package/src/domain.ts +11 -0
- package/src/index.tsx +1 -7
- package/src/keyboard/opentuiAdapter.ts +43 -0
- package/src/keymap/all.ts +103 -0
- package/src/keymap/closeModal.ts +13 -0
- package/src/keymap/commandPalette.ts +16 -0
- package/src/keymap/commentModal.ts +73 -0
- package/src/keymap/commentThreadModal.ts +24 -0
- package/src/keymap/detailView.ts +30 -0
- package/src/keymap/diffView.ts +91 -0
- package/src/keymap/filterMode.ts +13 -0
- package/src/keymap/helpers.ts +24 -0
- package/src/keymap/labelModal.ts +16 -0
- package/src/keymap/listNav.ts +119 -0
- package/src/keymap/mergeModal.ts +23 -0
- package/src/keymap/openRepositoryModal.ts +13 -0
- package/src/keymap/themeModal.ts +49 -0
- package/src/mergeActions.ts +4 -1
- package/src/services/GitHubService.ts +41 -6
- package/src/services/MockGitHubService.ts +37 -2
- package/src/themeStore.ts +28 -11
- package/src/ui/CommandPalette.tsx +115 -65
- package/src/ui/DetailsPane.tsx +152 -32
- package/src/ui/LoadingLogo.tsx +75 -0
- package/src/ui/PullRequestDiffPane.tsx +19 -17
- package/src/ui/comments.tsx +143 -0
- package/src/ui/diff.ts +194 -8
- package/src/ui/modals.tsx +4 -39
- package/src/ui/primitives.tsx +5 -1
- package/src/ui/singleLineInput.ts +2 -1
- package/src/ui/spinner.ts +1 -0
- package/src/keyboard/createKeymap.ts +0 -25
- package/src/keyboard/useAppCommandRegistry.ts +0 -30
- package/src/keyboard/useScopedBindings.ts +0 -66
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { TextAttributes } from "@opentui/core"
|
|
1
|
+
import { TextAttributes, type MouseEvent } from "@opentui/core"
|
|
2
2
|
import { useEffect, useMemo, useState } from "react"
|
|
3
3
|
import type { AppCommand } from "../commands.js"
|
|
4
4
|
import { clampCommandIndex } from "../commands.js"
|
|
5
5
|
import { colors } from "./colors.js"
|
|
6
6
|
import { scrollTopForVisibleLine } from "./diff.js"
|
|
7
|
-
import { centerCell, Filler, fitCell, HintRow,
|
|
7
|
+
import { centerCell, Divider, Filler, fitCell, HintRow, ModalFrame, PaddedRow, PlainLine, standardModalDims, TextLine, trimCell } from "./primitives.js"
|
|
8
8
|
|
|
9
9
|
const scopeLabels = {
|
|
10
10
|
Global: "App",
|
|
@@ -55,6 +55,9 @@ export const commandPaletteScrollTop = ({
|
|
|
55
55
|
return clampScrollTop(scrollTopForVisibleLine(current, listHeight, selectedRowIndex, 0))
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export const commandPaletteClampScrollTop = (rowsLength: number, listHeight: number, value: number) =>
|
|
59
|
+
Math.max(0, Math.min(value, Math.max(0, rowsLength - listHeight)))
|
|
60
|
+
|
|
58
61
|
export const CommandPalette = ({
|
|
59
62
|
commands,
|
|
60
63
|
query,
|
|
@@ -63,6 +66,8 @@ export const CommandPalette = ({
|
|
|
63
66
|
modalHeight,
|
|
64
67
|
offsetLeft,
|
|
65
68
|
offsetTop,
|
|
69
|
+
onSelectCommandIndex,
|
|
70
|
+
onRunCommand,
|
|
66
71
|
}: {
|
|
67
72
|
commands: readonly AppCommand[]
|
|
68
73
|
query: string
|
|
@@ -71,8 +76,11 @@ export const CommandPalette = ({
|
|
|
71
76
|
modalHeight: number
|
|
72
77
|
offsetLeft: number
|
|
73
78
|
offsetTop: number
|
|
79
|
+
onSelectCommandIndex: (index: number) => void
|
|
80
|
+
onRunCommand: (command: AppCommand) => void
|
|
74
81
|
}) => {
|
|
75
|
-
const {
|
|
82
|
+
const { innerWidth, contentWidth, rowWidth } = standardModalDims(modalWidth, modalHeight)
|
|
83
|
+
const listHeight = Math.max(1, modalHeight - 6)
|
|
76
84
|
const clampedIndex = clampCommandIndex(selectedIndex, commands)
|
|
77
85
|
const [scrollTop, setScrollTop] = useState(0)
|
|
78
86
|
const rows = useMemo(() => buildCommandPaletteRows(commands), [commands])
|
|
@@ -80,87 +88,129 @@ export const CommandPalette = ({
|
|
|
80
88
|
const visibleRows = rows.slice(scrollTop, scrollTop + listHeight)
|
|
81
89
|
const bottomPaddingRows = Math.max(0, listHeight - visibleRows.length)
|
|
82
90
|
const countText = commands.length === 1 ? "1 command" : `${commands.length} commands`
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
91
|
+
const placeholder = "Search"
|
|
92
|
+
const titleText = "Commands"
|
|
93
|
+
const headerGap = 1
|
|
94
|
+
const headerDivider = "│"
|
|
95
|
+
const searchGap = 1
|
|
96
|
+
const dividerColumn = 1 + titleText.length + headerGap
|
|
97
|
+
const searchStart = titleText.length + headerGap + headerDivider.length + searchGap
|
|
98
|
+
const countGap = countText.length > 0 ? 2 : 0
|
|
99
|
+
const searchWidth = Math.max(1, contentWidth - searchStart - countGap - countText.length)
|
|
100
|
+
const queryText = trimCell(query, Math.max(0, searchWidth - 1))
|
|
101
|
+
const queryPadding = Math.max(0, searchWidth - queryText.length - 1)
|
|
102
|
+
const caretFg = colors.background === "transparent" ? colors.text : colors.background
|
|
86
103
|
const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
|
|
87
104
|
const emptyBottomRows = Math.max(0, listHeight - emptyTopRows - 1)
|
|
105
|
+
const runCommandOnMouseDown = (command: AppCommand) => (event: MouseEvent) => {
|
|
106
|
+
if (event.button !== 0) return
|
|
107
|
+
event.preventDefault()
|
|
108
|
+
event.stopPropagation()
|
|
109
|
+
onRunCommand(command)
|
|
110
|
+
}
|
|
111
|
+
const selectCommandOnMouse = (commandIndex: number) => (event: MouseEvent) => {
|
|
112
|
+
onSelectCommandIndex(commandIndex)
|
|
113
|
+
event.stopPropagation()
|
|
114
|
+
}
|
|
115
|
+
const handleMouseScroll = (event: MouseEvent) => {
|
|
116
|
+
if (!event.scroll || rows.length <= listHeight) return
|
|
117
|
+
const delta = Math.max(1, Math.ceil(event.scroll.delta))
|
|
118
|
+
const direction = event.scroll.direction === "down" || event.scroll.direction === "right" ? 1 : -1
|
|
119
|
+
setScrollTop((current) => commandPaletteClampScrollTop(rows.length, listHeight, current + direction * delta))
|
|
120
|
+
event.preventDefault()
|
|
121
|
+
event.stopPropagation()
|
|
122
|
+
}
|
|
123
|
+
const content = rows.length === 0 ? (
|
|
124
|
+
<>
|
|
125
|
+
<Filler rows={emptyTopRows} prefix="top" />
|
|
126
|
+
<PlainLine text={centerCell("No matching command", rowWidth)} fg={colors.muted} />
|
|
127
|
+
<Filler rows={emptyBottomRows} prefix="bottom" />
|
|
128
|
+
</>
|
|
129
|
+
) : (
|
|
130
|
+
<>
|
|
131
|
+
{visibleRows.map((row, index) => {
|
|
132
|
+
const rowIndex = scrollTop + index
|
|
133
|
+
if (row._tag === "spacer") {
|
|
134
|
+
return <PlainLine key={`spacer-${rowIndex}`} text="" />
|
|
135
|
+
}
|
|
136
|
+
if (row._tag === "section") {
|
|
137
|
+
return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const { command, commandIndex } = row
|
|
141
|
+
const isSelected = commandIndex === clampedIndex
|
|
142
|
+
const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
|
|
143
|
+
const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
|
|
144
|
+
const trailingPadding = shortcut.length === 0 ? 0 : 1
|
|
145
|
+
// Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
|
|
146
|
+
const SELECTOR_WIDTH = 2
|
|
147
|
+
const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
|
|
148
|
+
const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
|
|
149
|
+
const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
|
|
150
|
+
const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
|
|
151
|
+
const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<box key={command.id} height={1} onMouseDown={runCommandOnMouseDown(command)} onMouseMove={selectCommandOnMouse(commandIndex)} onMouseOver={selectCommandOnMouse(commandIndex)}>
|
|
155
|
+
<TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
156
|
+
<span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
|
|
157
|
+
<span> </span>
|
|
158
|
+
{isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
|
|
159
|
+
{subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
|
|
160
|
+
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
161
|
+
{shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
|
|
162
|
+
{trailingPadding > 0 ? <span> </span> : null}
|
|
163
|
+
</TextLine>
|
|
164
|
+
</box>
|
|
165
|
+
)
|
|
166
|
+
})}
|
|
167
|
+
<Filler rows={bottomPaddingRows} prefix="pad" />
|
|
168
|
+
</>
|
|
169
|
+
)
|
|
88
170
|
useEffect(() => {
|
|
89
171
|
setScrollTop((current) => commandPaletteScrollTop({ current, rowsLength: rows.length, listHeight, selectedRowIndex }))
|
|
90
172
|
}, [listHeight, rows.length, selectedRowIndex])
|
|
91
|
-
|
|
92
173
|
return (
|
|
93
|
-
<
|
|
174
|
+
<ModalFrame
|
|
94
175
|
left={offsetLeft}
|
|
95
176
|
top={offsetTop}
|
|
96
177
|
width={modalWidth}
|
|
97
178
|
height={modalHeight}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
179
|
+
junctionRows={[1, modalHeight - 4]}
|
|
180
|
+
topJunctionColumns={[dividerColumn]}
|
|
181
|
+
>
|
|
182
|
+
<PaddedRow>
|
|
101
183
|
<TextLine>
|
|
184
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{titleText}</span>
|
|
185
|
+
<span>{" ".repeat(headerGap)}</span>
|
|
186
|
+
<span fg={colors.separator}>{headerDivider}</span>
|
|
187
|
+
<span>{" ".repeat(searchGap)}</span>
|
|
102
188
|
{query.length > 0 ? (
|
|
103
189
|
<>
|
|
104
190
|
<span fg={colors.text}>{queryText}</span>
|
|
105
|
-
<span bg={colors.
|
|
191
|
+
<span bg={colors.muted} fg={caretFg}> </span>
|
|
192
|
+
{queryPadding > 0 ? <span>{" ".repeat(queryPadding)}</span> : null}
|
|
106
193
|
</>
|
|
107
194
|
) : (
|
|
108
195
|
<>
|
|
109
|
-
<span bg={colors.
|
|
110
|
-
<span fg={colors.muted}>{fitCell(placeholder.slice(1), Math.max(0,
|
|
196
|
+
<span bg={colors.muted} fg={caretFg}>{placeholder[0]}</span>
|
|
197
|
+
<span fg={colors.muted}>{fitCell(placeholder.slice(1), Math.max(0, searchWidth - 1))}</span>
|
|
111
198
|
</>
|
|
112
199
|
)}
|
|
200
|
+
{countText.length > 0 && searchWidth > placeholder.length ? (
|
|
201
|
+
<>
|
|
202
|
+
<span>{" ".repeat(countGap)}</span>
|
|
203
|
+
<span fg={colors.muted}>{countText}</span>
|
|
204
|
+
</>
|
|
205
|
+
) : null}
|
|
113
206
|
</TextLine>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
{
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
</>
|
|
123
|
-
) : (
|
|
124
|
-
<>
|
|
125
|
-
{visibleRows.map((row, index) => {
|
|
126
|
-
const rowIndex = scrollTop + index
|
|
127
|
-
if (row._tag === "spacer") {
|
|
128
|
-
return <PlainLine key={`spacer-${rowIndex}`} text="" />
|
|
129
|
-
}
|
|
130
|
-
if (row._tag === "section") {
|
|
131
|
-
return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const { command, commandIndex } = row
|
|
135
|
-
const isSelected = commandIndex === clampedIndex
|
|
136
|
-
const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
|
|
137
|
-
const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
|
|
138
|
-
const trailingPadding = shortcut.length === 0 ? 0 : 1
|
|
139
|
-
// Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
|
|
140
|
-
const SELECTOR_WIDTH = 2
|
|
141
|
-
const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
|
|
142
|
-
const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
|
|
143
|
-
const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
|
|
144
|
-
const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
|
|
145
|
-
const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
|
|
146
|
-
|
|
147
|
-
return (
|
|
148
|
-
<box key={command.id} height={1}>
|
|
149
|
-
<TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
150
|
-
<span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
|
|
151
|
-
<span> </span>
|
|
152
|
-
{isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
|
|
153
|
-
{subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
|
|
154
|
-
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
155
|
-
{shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
|
|
156
|
-
{trailingPadding > 0 ? <span> </span> : null}
|
|
157
|
-
</TextLine>
|
|
158
|
-
</box>
|
|
159
|
-
)
|
|
160
|
-
})}
|
|
161
|
-
<Filler rows={bottomPaddingRows} prefix="pad" />
|
|
162
|
-
</>
|
|
163
|
-
)}
|
|
164
|
-
</StandardModal>
|
|
207
|
+
</PaddedRow>
|
|
208
|
+
<Divider width={innerWidth} junctionAt={dividerColumn} junctionChar="┴" />
|
|
209
|
+
<box height={listHeight} flexDirection="column" onMouseScroll={handleMouseScroll}>{content}</box>
|
|
210
|
+
<Divider width={innerWidth} />
|
|
211
|
+
<PaddedRow>
|
|
212
|
+
<HintRow items={[{ key: "↑↓", label: "select" }, { key: "enter", label: "run" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "close" }]} />
|
|
213
|
+
</PaddedRow>
|
|
214
|
+
</ModalFrame>
|
|
165
215
|
)
|
|
166
216
|
}
|
package/src/ui/DetailsPane.tsx
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
2
|
import { Fragment, useMemo } from "react"
|
|
3
3
|
import { formatRelativeDate } from "../date.js"
|
|
4
|
-
import type { CheckItem, PullRequestItem } from "../domain.js"
|
|
4
|
+
import type { CheckItem, PullRequestConversationItem, PullRequestItem } from "../domain.js"
|
|
5
5
|
import { colors, type ThemeId } from "./colors.js"
|
|
6
|
-
import {
|
|
6
|
+
import { commentCountText, commentDisplayRows, commentSideColor, CommentSegmentsLine, type CommentSegment } from "./comments.js"
|
|
7
|
+
import { diffCommentLineLabel, diffStatText } from "./diff.js"
|
|
7
8
|
import { DiffStats } from "./diffStats.js"
|
|
8
9
|
import { centerCell, Divider, Filler, fitCell, PaddedRow, PlainLine, TextLine } from "./primitives.js"
|
|
9
10
|
import { labelColor, labelTextColor, reviewLabel, shortRepoName, statusColor } from "./pullRequests.js"
|
|
10
11
|
|
|
11
12
|
interface PreviewLine {
|
|
12
|
-
readonly
|
|
13
|
-
|
|
14
|
-
readonly fg: string
|
|
15
|
-
readonly bold?: boolean
|
|
16
|
-
}>
|
|
13
|
+
readonly divider?: boolean
|
|
14
|
+
readonly segments: readonly CommentSegment[]
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
export interface DetailPlaceholderContent {
|
|
@@ -25,6 +23,8 @@ export const DETAIL_BODY_LINES = 6
|
|
|
25
23
|
export const DETAIL_PLACEHOLDER_ROWS = 4
|
|
26
24
|
export const DETAIL_BODY_SCROLL_LIMIT = 1_000
|
|
27
25
|
|
|
26
|
+
export type DetailConversationStatus = "idle" | "loading" | "ready"
|
|
27
|
+
|
|
28
28
|
const pullRequestReferencePattern = /(#[0-9]+)/g
|
|
29
29
|
const codeFencePattern = /^```\s*([a-zA-Z0-9_-]+)?/
|
|
30
30
|
const codeTokenPattern = /(\/\/.*|`(?:\\.|[^`])*`|"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\b(?:async|await|break|case|catch|class|const|continue|default|else|export|extends|finally|for|from|function|if|import|interface|let|new|return|switch|throw|try|type|var|while|yield)\b|\b(?:true|false|null|undefined)\b|\b\d+(?:\.\d+)?\b)/g
|
|
@@ -186,6 +186,85 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
|
|
|
186
186
|
return preview.slice(0, limit)
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
const previewDivider = (): PreviewLine => ({
|
|
190
|
+
divider: true,
|
|
191
|
+
segments: [],
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const conversationItemGroups = (item: PullRequestConversationItem, width: number): readonly (readonly CommentSegment[])[] => {
|
|
195
|
+
if (item._tag !== "review-comment") return []
|
|
196
|
+
const locationWidth = Math.max(8, width - item.author.length - 20)
|
|
197
|
+
return [[
|
|
198
|
+
{ text: fitCell(item.path, locationWidth), fg: colors.inlineCode },
|
|
199
|
+
{ text: diffCommentLineLabel(item), fg: commentSideColor(item.side), bold: true },
|
|
200
|
+
]]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const conversationPreview = ({
|
|
204
|
+
items,
|
|
205
|
+
status,
|
|
206
|
+
width,
|
|
207
|
+
limit,
|
|
208
|
+
}: {
|
|
209
|
+
readonly items: readonly PullRequestConversationItem[]
|
|
210
|
+
readonly status: DetailConversationStatus
|
|
211
|
+
readonly width: number
|
|
212
|
+
readonly limit: number
|
|
213
|
+
}): Array<PreviewLine> => {
|
|
214
|
+
if (status === "idle" || (status === "ready" && items.length === 0) || limit <= 0) return []
|
|
215
|
+
const rows: Array<PreviewLine> = []
|
|
216
|
+
const title = "Conversation"
|
|
217
|
+
const countText = status === "loading" ? "loading" : commentCountText(items.length)
|
|
218
|
+
const gap = Math.max(2, width - title.length - countText.length)
|
|
219
|
+
rows.push({
|
|
220
|
+
segments: [
|
|
221
|
+
{ text: title, fg: colors.count, bold: true },
|
|
222
|
+
{ text: " ".repeat(gap), fg: colors.muted },
|
|
223
|
+
{ text: countText, fg: colors.muted },
|
|
224
|
+
],
|
|
225
|
+
})
|
|
226
|
+
if (items.length === 0) {
|
|
227
|
+
rows.push({ segments: [{ text: "│ ", fg: colors.muted }, { text: "Loading comments...", fg: colors.muted }] })
|
|
228
|
+
return rows.slice(0, limit)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const item of items) {
|
|
232
|
+
if (rows.length >= limit) break
|
|
233
|
+
rows.push(...commentDisplayRows({ item, width, groups: conversationItemGroups(item, width) }).slice(0, limit - rows.length))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return rows.slice(0, limit)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const detailBodyPreview = ({
|
|
240
|
+
pullRequest,
|
|
241
|
+
contentWidth,
|
|
242
|
+
limit,
|
|
243
|
+
conversationItems,
|
|
244
|
+
conversationStatus,
|
|
245
|
+
}: {
|
|
246
|
+
readonly pullRequest: PullRequestItem
|
|
247
|
+
readonly contentWidth: number
|
|
248
|
+
readonly limit: number
|
|
249
|
+
readonly conversationItems: readonly PullRequestConversationItem[]
|
|
250
|
+
readonly conversationStatus: DetailConversationStatus
|
|
251
|
+
}) => {
|
|
252
|
+
const summaryRows = bodyPreview(pullRequest.body, contentWidth, limit)
|
|
253
|
+
const conversationRows = conversationPreview({
|
|
254
|
+
items: conversationItems,
|
|
255
|
+
status: conversationStatus,
|
|
256
|
+
width: contentWidth,
|
|
257
|
+
limit: Math.max(0, limit - summaryRows.length - 1),
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
if (conversationRows.length === 0) return summaryRows
|
|
261
|
+
return [
|
|
262
|
+
...summaryRows,
|
|
263
|
+
previewDivider(),
|
|
264
|
+
...conversationRows,
|
|
265
|
+
].slice(0, limit)
|
|
266
|
+
}
|
|
267
|
+
|
|
189
268
|
const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
|
|
190
269
|
const seen = new Map<string, CheckItem>()
|
|
191
270
|
for (const check of checks) {
|
|
@@ -264,13 +343,46 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
|
|
|
264
343
|
)
|
|
265
344
|
}
|
|
266
345
|
|
|
267
|
-
|
|
346
|
+
const conversationDividerBodyRow = (pullRequest: PullRequestItem, contentWidth: number, conversationItems: readonly PullRequestConversationItem[], conversationStatus: DetailConversationStatus) => {
|
|
347
|
+
if (!pullRequest.detailLoaded) return null
|
|
348
|
+
const dividerIndex = detailBodyPreview({ pullRequest, contentWidth, limit: DETAIL_BODY_SCROLL_LIMIT, conversationItems, conversationStatus })
|
|
349
|
+
.findIndex((line) => line.divider === true)
|
|
350
|
+
return dividerIndex >= 0 ? dividerIndex : null
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export const getDetailJunctionRows = ({
|
|
354
|
+
pullRequest,
|
|
355
|
+
paneWidth,
|
|
356
|
+
showChecks = false,
|
|
357
|
+
contentWidth,
|
|
358
|
+
conversationItems = [],
|
|
359
|
+
conversationStatus = "idle",
|
|
360
|
+
bodyScrollTop = 0,
|
|
361
|
+
bodyViewportHeight = Number.POSITIVE_INFINITY,
|
|
362
|
+
}: {
|
|
363
|
+
readonly pullRequest: PullRequestItem | null
|
|
364
|
+
readonly paneWidth: number
|
|
365
|
+
readonly showChecks?: boolean
|
|
366
|
+
readonly contentWidth?: number
|
|
367
|
+
readonly conversationItems?: readonly PullRequestConversationItem[]
|
|
368
|
+
readonly conversationStatus?: DetailConversationStatus
|
|
369
|
+
readonly bodyScrollTop?: number
|
|
370
|
+
readonly bodyViewportHeight?: number
|
|
371
|
+
}): readonly number[] => {
|
|
268
372
|
if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
|
|
373
|
+
const resolvedContentWidth = contentWidth ?? Math.max(1, paneWidth - 2)
|
|
269
374
|
const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
|
|
270
375
|
const detailDividerRow = 1 + titleLines + 1
|
|
271
376
|
const checks = deduplicateChecks(pullRequest.checks)
|
|
272
377
|
const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
|
|
273
|
-
|
|
378
|
+
const headerHeight = getDetailHeaderHeight(pullRequest, paneWidth, showChecks)
|
|
379
|
+
const conversationDivider = conversationDividerBodyRow(pullRequest, resolvedContentWidth, conversationItems, conversationStatus)
|
|
380
|
+
const visibleConversationDivider = conversationDivider === null ? null : conversationDivider - Math.max(0, Math.floor(bodyScrollTop))
|
|
381
|
+
return [
|
|
382
|
+
detailDividerRow,
|
|
383
|
+
showChecks && checks.length > 0 ? checksDividerRow : -1,
|
|
384
|
+
visibleConversationDivider === null || visibleConversationDivider < 0 || visibleConversationDivider >= bodyViewportHeight ? -1 : headerHeight + visibleConversationDivider,
|
|
385
|
+
].filter((row) => row >= 0)
|
|
274
386
|
}
|
|
275
387
|
|
|
276
388
|
export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false) => {
|
|
@@ -281,14 +393,14 @@ export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneW
|
|
|
281
393
|
return titleLines + 3 + checksHeight
|
|
282
394
|
}
|
|
283
395
|
|
|
284
|
-
export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES) => {
|
|
396
|
+
export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES, conversationItems: readonly PullRequestConversationItem[] = [], conversationStatus: DetailConversationStatus = "idle") => {
|
|
285
397
|
if (!pullRequest) return bodyLines
|
|
286
398
|
if (!pullRequest.detailLoaded) return bodyLines
|
|
287
|
-
return
|
|
399
|
+
return detailBodyPreview({ pullRequest, contentWidth, limit: bodyLines, conversationItems, conversationStatus }).length
|
|
288
400
|
}
|
|
289
401
|
|
|
290
|
-
export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number) => {
|
|
291
|
-
return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT)
|
|
402
|
+
export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, conversationItems: readonly PullRequestConversationItem[] = [], conversationStatus: DetailConversationStatus = "idle") => {
|
|
403
|
+
return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT, conversationItems, conversationStatus)
|
|
292
404
|
}
|
|
293
405
|
|
|
294
406
|
export const getDetailsPaneHeight = ({
|
|
@@ -297,14 +409,18 @@ export const getDetailsPaneHeight = ({
|
|
|
297
409
|
bodyLines = DETAIL_BODY_LINES,
|
|
298
410
|
paneWidth = contentWidth + 2,
|
|
299
411
|
showChecks = false,
|
|
412
|
+
conversationItems = [],
|
|
413
|
+
conversationStatus = "idle",
|
|
300
414
|
}: {
|
|
301
415
|
pullRequest: PullRequestItem | null
|
|
302
416
|
contentWidth: number
|
|
303
417
|
bodyLines?: number
|
|
304
418
|
paneWidth?: number
|
|
305
419
|
showChecks?: boolean
|
|
420
|
+
conversationItems?: readonly PullRequestConversationItem[]
|
|
421
|
+
conversationStatus?: DetailConversationStatus
|
|
306
422
|
}) => pullRequest
|
|
307
|
-
? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines)
|
|
423
|
+
? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines, conversationItems, conversationStatus)
|
|
308
424
|
: bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
|
|
309
425
|
|
|
310
426
|
export const DetailHeader = ({
|
|
@@ -394,21 +510,27 @@ export const DetailHeader = ({
|
|
|
394
510
|
export const DetailBody = ({
|
|
395
511
|
pullRequest,
|
|
396
512
|
contentWidth,
|
|
513
|
+
paneWidth = contentWidth + 2,
|
|
397
514
|
bodyLines = DETAIL_BODY_LINES,
|
|
398
515
|
bodyLineLimit = bodyLines,
|
|
516
|
+
conversationItems = [],
|
|
517
|
+
conversationStatus = "idle",
|
|
399
518
|
loadingIndicator,
|
|
400
519
|
themeId,
|
|
401
520
|
}: {
|
|
402
521
|
pullRequest: PullRequestItem
|
|
403
522
|
contentWidth: number
|
|
523
|
+
paneWidth?: number
|
|
404
524
|
bodyLines?: number
|
|
405
525
|
bodyLineLimit?: number
|
|
526
|
+
conversationItems?: readonly PullRequestConversationItem[]
|
|
527
|
+
conversationStatus?: DetailConversationStatus
|
|
406
528
|
loadingIndicator: string
|
|
407
529
|
themeId: ThemeId
|
|
408
530
|
}) => {
|
|
409
531
|
const previewLines = useMemo(
|
|
410
|
-
() =>
|
|
411
|
-
[pullRequest
|
|
532
|
+
() => detailBodyPreview({ pullRequest, contentWidth, limit: bodyLineLimit, conversationItems, conversationStatus }),
|
|
533
|
+
[pullRequest, contentWidth, bodyLineLimit, conversationItems, conversationStatus, themeId],
|
|
412
534
|
)
|
|
413
535
|
|
|
414
536
|
if (!pullRequest.detailLoaded) {
|
|
@@ -424,21 +546,15 @@ export const DetailBody = ({
|
|
|
424
546
|
}
|
|
425
547
|
|
|
426
548
|
return (
|
|
427
|
-
<box flexDirection="column"
|
|
549
|
+
<box flexDirection="column" height={previewLines.length}>
|
|
428
550
|
{previewLines.map((line, index) => (
|
|
429
|
-
|
|
430
|
-
{
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
<span key={segmentIndex} fg={segment.fg}>
|
|
437
|
-
{segment.text}
|
|
438
|
-
</span>
|
|
439
|
-
)
|
|
440
|
-
))}
|
|
441
|
-
</TextLine>
|
|
551
|
+
line.divider === true ? (
|
|
552
|
+
<Divider key={`${pullRequest.url}-${index}`} width={paneWidth} />
|
|
553
|
+
) : (
|
|
554
|
+
<PaddedRow key={`${pullRequest.url}-${index}`}>
|
|
555
|
+
<CommentSegmentsLine segments={line.segments} />
|
|
556
|
+
</PaddedRow>
|
|
557
|
+
)
|
|
442
558
|
))}
|
|
443
559
|
</box>
|
|
444
560
|
)
|
|
@@ -499,6 +615,8 @@ export const DetailsPane = ({
|
|
|
499
615
|
bodyLineLimit = bodyLines,
|
|
500
616
|
paneWidth = contentWidth + 2,
|
|
501
617
|
showChecks = false,
|
|
618
|
+
conversationItems = [],
|
|
619
|
+
conversationStatus = "idle",
|
|
502
620
|
placeholderContent,
|
|
503
621
|
loadingIndicator,
|
|
504
622
|
themeId,
|
|
@@ -510,18 +628,20 @@ export const DetailsPane = ({
|
|
|
510
628
|
bodyLineLimit?: number
|
|
511
629
|
paneWidth?: number
|
|
512
630
|
showChecks?: boolean
|
|
631
|
+
conversationItems?: readonly PullRequestConversationItem[]
|
|
632
|
+
conversationStatus?: DetailConversationStatus
|
|
513
633
|
placeholderContent: DetailPlaceholderContent
|
|
514
634
|
loadingIndicator: string
|
|
515
635
|
themeId: ThemeId
|
|
516
636
|
}) => {
|
|
517
|
-
const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks })
|
|
637
|
+
const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks, conversationItems, conversationStatus })
|
|
518
638
|
|
|
519
639
|
return (
|
|
520
640
|
<box flexDirection="column" height={contentHeight}>
|
|
521
641
|
{pullRequest ? (
|
|
522
642
|
<>
|
|
523
643
|
<DetailHeader pullRequest={pullRequest} viewerUsername={viewerUsername} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
|
|
524
|
-
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} bodyLines={bodyLines} bodyLineLimit={bodyLineLimit} loadingIndicator={loadingIndicator} themeId={themeId} />
|
|
644
|
+
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} bodyLines={bodyLines} bodyLineLimit={bodyLineLimit} conversationItems={conversationItems} conversationStatus={conversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
|
|
525
645
|
</>
|
|
526
646
|
) : (
|
|
527
647
|
<>
|
|
@@ -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
|
+
}
|