@kitlangton/ghui 0.1.21 → 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 +9 -4
- package/src/App.tsx +620 -792
- package/src/appCommands.ts +70 -16
- package/src/domain.ts +13 -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 +42 -6
- package/src/services/MockGitHubService.ts +37 -2
- package/src/themeStore.ts +28 -11
- package/src/ui/CommandPalette.tsx +123 -63
- package/src/ui/DetailsPane.tsx +192 -54
- package/src/ui/FooterHints.tsx +9 -18
- package/src/ui/LoadingLogo.tsx +75 -0
- package/src/ui/PullRequestDiffPane.tsx +25 -18
- package/src/ui/PullRequestList.tsx +6 -2
- package/src/ui/comments.tsx +143 -0
- package/src/ui/diff.ts +198 -6
- 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
|
@@ -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,77 +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
|
|
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
|
|
85
103
|
const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
|
|
86
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
|
+
)
|
|
87
170
|
useEffect(() => {
|
|
88
171
|
setScrollTop((current) => commandPaletteScrollTop({ current, rowsLength: rows.length, listHeight, selectedRowIndex }))
|
|
89
172
|
}, [listHeight, rows.length, selectedRowIndex])
|
|
90
|
-
|
|
91
173
|
return (
|
|
92
|
-
<
|
|
174
|
+
<ModalFrame
|
|
93
175
|
left={offsetLeft}
|
|
94
176
|
top={offsetTop}
|
|
95
177
|
width={modalWidth}
|
|
96
178
|
height={modalHeight}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
179
|
+
junctionRows={[1, modalHeight - 4]}
|
|
180
|
+
topJunctionColumns={[dividerColumn]}
|
|
181
|
+
>
|
|
182
|
+
<PaddedRow>
|
|
100
183
|
<TextLine>
|
|
101
|
-
<span fg={colors.
|
|
102
|
-
<span
|
|
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>
|
|
188
|
+
{query.length > 0 ? (
|
|
189
|
+
<>
|
|
190
|
+
<span fg={colors.text}>{queryText}</span>
|
|
191
|
+
<span bg={colors.muted} fg={caretFg}> </span>
|
|
192
|
+
{queryPadding > 0 ? <span>{" ".repeat(queryPadding)}</span> : null}
|
|
193
|
+
</>
|
|
194
|
+
) : (
|
|
195
|
+
<>
|
|
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>
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
{countText.length > 0 && searchWidth > placeholder.length ? (
|
|
201
|
+
<>
|
|
202
|
+
<span>{" ".repeat(countGap)}</span>
|
|
203
|
+
<span fg={colors.muted}>{countText}</span>
|
|
204
|
+
</>
|
|
205
|
+
) : null}
|
|
103
206
|
</TextLine>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
{
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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>
|
|
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>
|
|
155
215
|
)
|
|
156
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,7 +23,12 @@ 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
|
+
const codeFencePattern = /^```\s*([a-zA-Z0-9_-]+)?/
|
|
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
|
|
31
|
+
const codeFenceLine = (line: string) => line.trim().replace(/\\`/g, "`").match(codeFencePattern)
|
|
29
32
|
|
|
30
33
|
export const wrapText = (text: string, width: number): string[] => {
|
|
31
34
|
if (text.length === 0 || width <= 0) return [""]
|
|
@@ -63,6 +66,29 @@ const parseInlineSegments = (text: string, fg: string, bold = false): PreviewLin
|
|
|
63
66
|
})
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
const parseCodeSegments = (text: string): PreviewLine["segments"] => {
|
|
70
|
+
const segments: Array<PreviewLine["segments"][number]> = []
|
|
71
|
+
let index = 0
|
|
72
|
+
for (const match of text.matchAll(codeTokenPattern)) {
|
|
73
|
+
const start = match.index ?? 0
|
|
74
|
+
if (start > index) segments.push({ text: text.slice(index, start), fg: colors.text })
|
|
75
|
+
const token = match[0]
|
|
76
|
+
const fg = token.startsWith("//")
|
|
77
|
+
? colors.muted
|
|
78
|
+
: token.startsWith("`") || token.startsWith("\"") || token.startsWith("'")
|
|
79
|
+
? colors.inlineCode
|
|
80
|
+
: /^\d/.test(token)
|
|
81
|
+
? colors.status.review
|
|
82
|
+
: token === "true" || token === "false" || token === "null" || token === "undefined"
|
|
83
|
+
? colors.status.review
|
|
84
|
+
: colors.accent
|
|
85
|
+
segments.push({ text: token, fg, bold: fg === colors.accent })
|
|
86
|
+
index = start + token.length
|
|
87
|
+
}
|
|
88
|
+
if (index < text.length) segments.push({ text: text.slice(index), fg: colors.text })
|
|
89
|
+
return segments.length > 0 ? segments : [{ text: "", fg: colors.muted }]
|
|
90
|
+
}
|
|
91
|
+
|
|
66
92
|
const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, indent = ""): Array<PreviewLine> => {
|
|
67
93
|
const tokens = segments.flatMap((segment) =>
|
|
68
94
|
segment.text.split(/(\s+)/).filter((token) => token.length > 0).map((token) => ({ ...segment, text: token })),
|
|
@@ -102,11 +128,13 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
|
|
|
102
128
|
for (const rawLine of sourceLines) {
|
|
103
129
|
if (preview.length >= limit) break
|
|
104
130
|
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
131
|
+
const fence = codeFenceLine(rawLine)
|
|
132
|
+
if (fence) {
|
|
107
133
|
inCodeBlock = !inCodeBlock
|
|
108
134
|
continue
|
|
109
135
|
}
|
|
136
|
+
|
|
137
|
+
const line = inCodeBlock ? rawLine.replace(/\t/g, " ") : rawLine.trim()
|
|
110
138
|
if (line.length === 0) continue
|
|
111
139
|
|
|
112
140
|
let text = line
|
|
@@ -142,11 +170,9 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
|
|
|
142
170
|
text = `> ${line.replace(/^>\s+/, "")}`
|
|
143
171
|
fg = colors.muted
|
|
144
172
|
indent = " "
|
|
145
|
-
} else if (inCodeBlock) {
|
|
146
|
-
fg = colors.muted
|
|
147
173
|
}
|
|
148
174
|
|
|
149
|
-
const wrapped = wrapPreviewSegments(parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
|
|
175
|
+
const wrapped = wrapPreviewSegments(inCodeBlock ? parseCodeSegments(text) : parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
|
|
150
176
|
for (const wrappedLine of wrapped) {
|
|
151
177
|
preview.push(wrappedLine)
|
|
152
178
|
if (preview.length >= limit) break
|
|
@@ -160,6 +186,85 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
|
|
|
160
186
|
return preview.slice(0, limit)
|
|
161
187
|
}
|
|
162
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
|
+
|
|
163
268
|
const deduplicateChecks = (checks: readonly CheckItem[]): CheckItem[] => {
|
|
164
269
|
const seen = new Map<string, CheckItem>()
|
|
165
270
|
for (const check of checks) {
|
|
@@ -204,9 +309,10 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
|
|
|
204
309
|
const unique = deduplicateChecks(checks)
|
|
205
310
|
if (unique.length === 0) return null
|
|
206
311
|
|
|
207
|
-
const
|
|
312
|
+
const columns = 2
|
|
313
|
+
const colWidth = Math.floor((contentWidth - 1) / columns)
|
|
208
314
|
const nameCol = Math.max(4, colWidth - 2)
|
|
209
|
-
const rows = Math.ceil(unique.length /
|
|
315
|
+
const rows = Math.ceil(unique.length / columns)
|
|
210
316
|
|
|
211
317
|
return (
|
|
212
318
|
<box flexDirection="column">
|
|
@@ -214,23 +320,22 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
|
|
|
214
320
|
<span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
|
|
215
321
|
</TextLine>
|
|
216
322
|
{Array.from({ length: rows }, (_, rowIndex) => {
|
|
217
|
-
const left = unique[rowIndex * 2]
|
|
218
|
-
const right = unique[rowIndex * 2 + 1]
|
|
219
323
|
return (
|
|
220
324
|
<TextLine key={rowIndex}>
|
|
221
|
-
{
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
325
|
+
{Array.from({ length: columns }, (_, columnIndex) => {
|
|
326
|
+
const check = unique[rowIndex * columns + columnIndex]
|
|
327
|
+
return (
|
|
328
|
+
<Fragment key={columnIndex}>
|
|
329
|
+
{columnIndex > 0 ? <span fg={colors.muted}> </span> : null}
|
|
330
|
+
{check ? (
|
|
331
|
+
<>
|
|
332
|
+
<span fg={checkColor(check)}>{checkIcon(check)} </span>
|
|
333
|
+
<span fg={colors.text}>{fitCell(check.name, nameCol)}</span>
|
|
334
|
+
</>
|
|
335
|
+
) : <span>{" ".repeat(colWidth)}</span>}
|
|
336
|
+
</Fragment>
|
|
337
|
+
)
|
|
338
|
+
})}
|
|
234
339
|
</TextLine>
|
|
235
340
|
)
|
|
236
341
|
})}
|
|
@@ -238,13 +343,38 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
|
|
|
238
343
|
)
|
|
239
344
|
}
|
|
240
345
|
|
|
241
|
-
|
|
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[] => {
|
|
242
366
|
if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
|
|
243
367
|
const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
|
|
244
368
|
const detailDividerRow = 1 + titleLines + 1
|
|
245
369
|
const checks = deduplicateChecks(pullRequest.checks)
|
|
246
370
|
const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
|
|
247
|
-
|
|
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)
|
|
248
378
|
}
|
|
249
379
|
|
|
250
380
|
export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneWidth: number, showChecks = false) => {
|
|
@@ -255,14 +385,14 @@ export const getDetailHeaderHeight = (pullRequest: PullRequestItem | null, paneW
|
|
|
255
385
|
return titleLines + 3 + checksHeight
|
|
256
386
|
}
|
|
257
387
|
|
|
258
|
-
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") => {
|
|
259
389
|
if (!pullRequest) return bodyLines
|
|
260
390
|
if (!pullRequest.detailLoaded) return bodyLines
|
|
261
|
-
return
|
|
391
|
+
return detailBodyPreview({ pullRequest, contentWidth, limit: bodyLines, conversationItems, conversationStatus }).length
|
|
262
392
|
}
|
|
263
393
|
|
|
264
|
-
export const getScrollableDetailBodyHeight = (pullRequest: PullRequestItem | null, contentWidth: number) => {
|
|
265
|
-
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)
|
|
266
396
|
}
|
|
267
397
|
|
|
268
398
|
export const getDetailsPaneHeight = ({
|
|
@@ -271,14 +401,18 @@ export const getDetailsPaneHeight = ({
|
|
|
271
401
|
bodyLines = DETAIL_BODY_LINES,
|
|
272
402
|
paneWidth = contentWidth + 2,
|
|
273
403
|
showChecks = false,
|
|
404
|
+
conversationItems = [],
|
|
405
|
+
conversationStatus = "idle",
|
|
274
406
|
}: {
|
|
275
407
|
pullRequest: PullRequestItem | null
|
|
276
408
|
contentWidth: number
|
|
277
409
|
bodyLines?: number
|
|
278
410
|
paneWidth?: number
|
|
279
411
|
showChecks?: boolean
|
|
412
|
+
conversationItems?: readonly PullRequestConversationItem[]
|
|
413
|
+
conversationStatus?: DetailConversationStatus
|
|
280
414
|
}) => pullRequest
|
|
281
|
-
? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines)
|
|
415
|
+
? getDetailHeaderHeight(pullRequest, paneWidth, showChecks) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines, conversationItems, conversationStatus)
|
|
282
416
|
: bodyLines + DETAIL_PLACEHOLDER_ROWS + 1
|
|
283
417
|
|
|
284
418
|
export const DetailHeader = ({
|
|
@@ -368,21 +502,27 @@ export const DetailHeader = ({
|
|
|
368
502
|
export const DetailBody = ({
|
|
369
503
|
pullRequest,
|
|
370
504
|
contentWidth,
|
|
505
|
+
paneWidth = contentWidth + 2,
|
|
371
506
|
bodyLines = DETAIL_BODY_LINES,
|
|
372
507
|
bodyLineLimit = bodyLines,
|
|
508
|
+
conversationItems = [],
|
|
509
|
+
conversationStatus = "idle",
|
|
373
510
|
loadingIndicator,
|
|
374
511
|
themeId,
|
|
375
512
|
}: {
|
|
376
513
|
pullRequest: PullRequestItem
|
|
377
514
|
contentWidth: number
|
|
515
|
+
paneWidth?: number
|
|
378
516
|
bodyLines?: number
|
|
379
517
|
bodyLineLimit?: number
|
|
518
|
+
conversationItems?: readonly PullRequestConversationItem[]
|
|
519
|
+
conversationStatus?: DetailConversationStatus
|
|
380
520
|
loadingIndicator: string
|
|
381
521
|
themeId: ThemeId
|
|
382
522
|
}) => {
|
|
383
523
|
const previewLines = useMemo(
|
|
384
|
-
() =>
|
|
385
|
-
[pullRequest
|
|
524
|
+
() => detailBodyPreview({ pullRequest, contentWidth, limit: bodyLineLimit, conversationItems, conversationStatus }),
|
|
525
|
+
[pullRequest, contentWidth, bodyLineLimit, conversationItems, conversationStatus, themeId],
|
|
386
526
|
)
|
|
387
527
|
|
|
388
528
|
if (!pullRequest.detailLoaded) {
|
|
@@ -398,21 +538,15 @@ export const DetailBody = ({
|
|
|
398
538
|
}
|
|
399
539
|
|
|
400
540
|
return (
|
|
401
|
-
<box flexDirection="column"
|
|
541
|
+
<box flexDirection="column" height={previewLines.length}>
|
|
402
542
|
{previewLines.map((line, index) => (
|
|
403
|
-
|
|
404
|
-
{
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
<span key={segmentIndex} fg={segment.fg}>
|
|
411
|
-
{segment.text}
|
|
412
|
-
</span>
|
|
413
|
-
)
|
|
414
|
-
))}
|
|
415
|
-
</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
|
+
)
|
|
416
550
|
))}
|
|
417
551
|
</box>
|
|
418
552
|
)
|
|
@@ -473,6 +607,8 @@ export const DetailsPane = ({
|
|
|
473
607
|
bodyLineLimit = bodyLines,
|
|
474
608
|
paneWidth = contentWidth + 2,
|
|
475
609
|
showChecks = false,
|
|
610
|
+
conversationItems = [],
|
|
611
|
+
conversationStatus = "idle",
|
|
476
612
|
placeholderContent,
|
|
477
613
|
loadingIndicator,
|
|
478
614
|
themeId,
|
|
@@ -484,18 +620,20 @@ export const DetailsPane = ({
|
|
|
484
620
|
bodyLineLimit?: number
|
|
485
621
|
paneWidth?: number
|
|
486
622
|
showChecks?: boolean
|
|
623
|
+
conversationItems?: readonly PullRequestConversationItem[]
|
|
624
|
+
conversationStatus?: DetailConversationStatus
|
|
487
625
|
placeholderContent: DetailPlaceholderContent
|
|
488
626
|
loadingIndicator: string
|
|
489
627
|
themeId: ThemeId
|
|
490
628
|
}) => {
|
|
491
|
-
const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks })
|
|
629
|
+
const contentHeight = getDetailsPaneHeight({ pullRequest, contentWidth, bodyLines: bodyLineLimit, paneWidth, showChecks, conversationItems, conversationStatus })
|
|
492
630
|
|
|
493
631
|
return (
|
|
494
632
|
<box flexDirection="column" height={contentHeight}>
|
|
495
633
|
{pullRequest ? (
|
|
496
634
|
<>
|
|
497
635
|
<DetailHeader pullRequest={pullRequest} viewerUsername={viewerUsername} contentWidth={contentWidth} paneWidth={paneWidth} showChecks={showChecks} />
|
|
498
|
-
<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} />
|
|
499
637
|
</>
|
|
500
638
|
) : (
|
|
501
639
|
<>
|