@kitlangton/ghui 0.1.22 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/App.tsx +338 -353
- 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 +144 -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,38 @@ 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 summaryRows = bodyPreview(pullRequest.body, contentWidth, DETAIL_BODY_SCROLL_LIMIT)
|
|
349
|
+
const conversationRows = conversationPreview({
|
|
350
|
+
items: conversationItems,
|
|
351
|
+
status: conversationStatus,
|
|
352
|
+
width: contentWidth,
|
|
353
|
+
limit: Math.max(0, DETAIL_BODY_SCROLL_LIMIT - summaryRows.length - 1),
|
|
354
|
+
})
|
|
355
|
+
return conversationRows.length > 0 ? summaryRows.length : null
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export const getDetailJunctionRows = (
|
|
359
|
+
pullRequest: PullRequestItem | null,
|
|
360
|
+
paneWidth: number,
|
|
361
|
+
showChecks = false,
|
|
362
|
+
contentWidth = Math.max(1, paneWidth - 2),
|
|
363
|
+
conversationItems: readonly PullRequestConversationItem[] = [],
|
|
364
|
+
conversationStatus: DetailConversationStatus = "idle",
|
|
365
|
+
): readonly number[] => {
|
|
268
366
|
if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
|
|
269
367
|
const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
|
|
270
368
|
const detailDividerRow = 1 + titleLines + 1
|
|
271
369
|
const checks = deduplicateChecks(pullRequest.checks)
|
|
272
370
|
const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
|
|
273
|
-
|
|
371
|
+
const headerHeight = getDetailHeaderHeight(pullRequest, paneWidth, showChecks)
|
|
372
|
+
const conversationDivider = conversationDividerBodyRow(pullRequest, contentWidth, conversationItems, conversationStatus)
|
|
373
|
+
return [
|
|
374
|
+
detailDividerRow,
|
|
375
|
+
showChecks && checks.length > 0 ? checksDividerRow : -1,
|
|
376
|
+
conversationDivider === null ? -1 : headerHeight + conversationDivider,
|
|
377
|
+
].filter((row) => row >= 0)
|
|
274
378
|
}
|
|
275
379
|
|
|
276
380
|
export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false) => {
|
|
@@ -281,14 +385,14 @@ export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneW
|
|
|
281
385
|
return titleLines + 3 + checksHeight
|
|
282
386
|
}
|
|
283
387
|
|
|
284
|
-
export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES) => {
|
|
388
|
+
export const getDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, bodyLines = DETAIL_BODY_LINES, conversationItems: readonly PullRequestConversationItem[] = [], conversationStatus: DetailConversationStatus = "idle") => {
|
|
285
389
|
if (!pullRequest) return bodyLines
|
|
286
390
|
if (!pullRequest.detailLoaded) return bodyLines
|
|
287
|
-
return
|
|
391
|
+
return detailBodyPreview({ pullRequest, contentWidth, limit: bodyLines, conversationItems, conversationStatus }).length
|
|
288
392
|
}
|
|
289
393
|
|
|
290
|
-
export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number) => {
|
|
291
|
-
return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT)
|
|
394
|
+
export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number, conversationItems: readonly PullRequestConversationItem[] = [], conversationStatus: DetailConversationStatus = "idle") => {
|
|
395
|
+
return getDetailBodyHeight(pullRequest, contentWidth, DETAIL_BODY_SCROLL_LIMIT, conversationItems, conversationStatus)
|
|
292
396
|
}
|
|
293
397
|
|
|
294
398
|
export const getDetailsPaneHeight = ({
|
|
@@ -297,14 +401,18 @@ export const getDetailsPaneHeight = ({
|
|
|
297
401
|
bodyLines = DETAIL_BODY_LINES,
|
|
298
402
|
paneWidth = contentWidth + 2,
|
|
299
403
|
showChecks = false,
|
|
404
|
+
conversationItems = [],
|
|
405
|
+
conversationStatus = "idle",
|
|
300
406
|
}: {
|
|
301
407
|
pullRequest: PullRequestItem | null
|
|
302
408
|
contentWidth: number
|
|
303
409
|
bodyLines?: number
|
|
304
410
|
paneWidth?: number
|
|
305
411
|
showChecks?: boolean
|
|
412
|
+
conversationItems?: readonly PullRequestConversationItem[]
|
|
413
|
+
conversationStatus?: DetailConversationStatus
|
|
306
414
|
}) => pullRequest
|
|
307
|
-
? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines)
|
|
415
|
+
? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines, conversationItems, conversationStatus)
|
|
308
416
|
: bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
|
|
309
417
|
|
|
310
418
|
export const DetailHeader = ({
|
|
@@ -394,21 +502,27 @@ export const DetailHeader = ({
|
|
|
394
502
|
export const DetailBody = ({
|
|
395
503
|
pullRequest,
|
|
396
504
|
contentWidth,
|
|
505
|
+
paneWidth = contentWidth + 2,
|
|
397
506
|
bodyLines = DETAIL_BODY_LINES,
|
|
398
507
|
bodyLineLimit = bodyLines,
|
|
508
|
+
conversationItems = [],
|
|
509
|
+
conversationStatus = "idle",
|
|
399
510
|
loadingIndicator,
|
|
400
511
|
themeId,
|
|
401
512
|
}: {
|
|
402
513
|
pullRequest: PullRequestItem
|
|
403
514
|
contentWidth: number
|
|
515
|
+
paneWidth?: number
|
|
404
516
|
bodyLines?: number
|
|
405
517
|
bodyLineLimit?: number
|
|
518
|
+
conversationItems?: readonly PullRequestConversationItem[]
|
|
519
|
+
conversationStatus?: DetailConversationStatus
|
|
406
520
|
loadingIndicator: string
|
|
407
521
|
themeId: ThemeId
|
|
408
522
|
}) => {
|
|
409
523
|
const previewLines = useMemo(
|
|
410
|
-
() =>
|
|
411
|
-
[pullRequest
|
|
524
|
+
() => detailBodyPreview({ pullRequest, contentWidth, limit: bodyLineLimit, conversationItems, conversationStatus }),
|
|
525
|
+
[pullRequest, contentWidth, bodyLineLimit, conversationItems, conversationStatus, themeId],
|
|
412
526
|
)
|
|
413
527
|
|
|
414
528
|
if (!pullRequest.detailLoaded) {
|
|
@@ -424,21 +538,15 @@ export const DetailBody = ({
|
|
|
424
538
|
}
|
|
425
539
|
|
|
426
540
|
return (
|
|
427
|
-
<box flexDirection="column"
|
|
541
|
+
<box flexDirection="column" height={previewLines.length}>
|
|
428
542
|
{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>
|
|
543
|
+
line.divider === true ? (
|
|
544
|
+
<Divider key={`${pullRequest.url}-${index}`} width={paneWidth} />
|
|
545
|
+
) : (
|
|
546
|
+
<PaddedRow key={`${pullRequest.url}-${index}`}>
|
|
547
|
+
<CommentSegmentsLine segments={line.segments} />
|
|
548
|
+
</PaddedRow>
|
|
549
|
+
)
|
|
442
550
|
))}
|
|
443
551
|
</box>
|
|
444
552
|
)
|
|
@@ -499,6 +607,8 @@ export const DetailsPane = ({
|
|
|
499
607
|
bodyLineLimit = bodyLines,
|
|
500
608
|
paneWidth = contentWidth + 2,
|
|
501
609
|
showChecks = false,
|
|
610
|
+
conversationItems = [],
|
|
611
|
+
conversationStatus = "idle",
|
|
502
612
|
placeholderContent,
|
|
503
613
|
loadingIndicator,
|
|
504
614
|
themeId,
|
|
@@ -510,18 +620,20 @@ export const DetailsPane = ({
|
|
|
510
620
|
bodyLineLimit?: number
|
|
511
621
|
paneWidth?: number
|
|
512
622
|
showChecks?: boolean
|
|
623
|
+
conversationItems?: readonly PullRequestConversationItem[]
|
|
624
|
+
conversationStatus?: DetailConversationStatus
|
|
513
625
|
placeholderContent: DetailPlaceholderContent
|
|
514
626
|
loadingIndicator: string
|
|
515
627
|
themeId: ThemeId
|
|
516
628
|
}) => {
|
|
517
|
-
const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks })
|
|
629
|
+
const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks, conversationItems, conversationStatus })
|
|
518
630
|
|
|
519
631
|
return (
|
|
520
632
|
<box flexDirection="column" height={contentHeight}>
|
|
521
633
|
{pullRequest ? (
|
|
522
634
|
<>
|
|
523
635
|
<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} />
|
|
636
|
+
<DetailBody pullRequest={pullRequest} contentWidth={contentWidth} paneWidth={paneWidth} bodyLines={bodyLines} bodyLineLimit={bodyLineLimit} conversationItems={conversationItems} conversationStatus={conversationStatus} loadingIndicator={loadingIndicator} themeId={themeId} />
|
|
525
637
|
</>
|
|
526
638
|
) : (
|
|
527
639
|
<>
|
|
@@ -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
|
+
}
|