@kitlangton/ghui 0.3.0 → 0.3.3

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.
Files changed (55) hide show
  1. package/README.md +13 -3
  2. package/bin/ghui.js +6 -1
  3. package/dist/index.js +9456 -0
  4. package/package.json +7 -4
  5. package/src/App.tsx +0 -2779
  6. package/src/appCommands.ts +0 -409
  7. package/src/commands.ts +0 -73
  8. package/src/config.ts +0 -20
  9. package/src/date.ts +0 -20
  10. package/src/domain.ts +0 -149
  11. package/src/errors.ts +0 -10
  12. package/src/index.tsx +0 -50
  13. package/src/keyboard/opentuiAdapter.ts +0 -43
  14. package/src/keymap/all.ts +0 -116
  15. package/src/keymap/changedFilesModal.ts +0 -23
  16. package/src/keymap/closeModal.ts +0 -13
  17. package/src/keymap/commandPalette.ts +0 -16
  18. package/src/keymap/commentModal.ts +0 -73
  19. package/src/keymap/commentThreadModal.ts +0 -24
  20. package/src/keymap/detailView.ts +0 -30
  21. package/src/keymap/diffView.ts +0 -93
  22. package/src/keymap/filterMode.ts +0 -13
  23. package/src/keymap/helpers.ts +0 -24
  24. package/src/keymap/labelModal.ts +0 -16
  25. package/src/keymap/listNav.ts +0 -119
  26. package/src/keymap/mergeModal.ts +0 -23
  27. package/src/keymap/openRepositoryModal.ts +0 -13
  28. package/src/keymap/submitReviewModal.ts +0 -48
  29. package/src/keymap/themeModal.ts +0 -49
  30. package/src/mergeActions.ts +0 -88
  31. package/src/observability.ts +0 -46
  32. package/src/pullRequestCache.ts +0 -19
  33. package/src/pullRequestViews.ts +0 -43
  34. package/src/services/BrowserOpener.ts +0 -22
  35. package/src/services/Clipboard.ts +0 -46
  36. package/src/services/CommandRunner.ts +0 -91
  37. package/src/services/GitHubService.ts +0 -756
  38. package/src/services/MockGitHubService.ts +0 -182
  39. package/src/themeStore.ts +0 -60
  40. package/src/ui/CommandPalette.tsx +0 -176
  41. package/src/ui/DetailsPane.tsx +0 -656
  42. package/src/ui/FooterHints.tsx +0 -90
  43. package/src/ui/LoadingLogo.tsx +0 -75
  44. package/src/ui/PullRequestDiffPane.tsx +0 -249
  45. package/src/ui/PullRequestList.tsx +0 -194
  46. package/src/ui/colors.ts +0 -821
  47. package/src/ui/commentEditor.ts +0 -126
  48. package/src/ui/comments.tsx +0 -143
  49. package/src/ui/diff.ts +0 -650
  50. package/src/ui/diffStats.tsx +0 -25
  51. package/src/ui/modals.tsx +0 -964
  52. package/src/ui/primitives.tsx +0 -350
  53. package/src/ui/pullRequests.ts +0 -106
  54. package/src/ui/singleLineInput.ts +0 -26
  55. package/src/ui/spinner.ts +0 -1
@@ -1,90 +0,0 @@
1
- import { Data } from "effect"
2
- import { colors } from "./colors.js"
3
- import { HintRow, type HintItem } from "./primitives.js"
4
-
5
- export type RetryProgress = Data.TaggedEnum<{
6
- Idle: {}
7
- Retrying: { readonly attempt: number; readonly max: number }
8
- }>
9
-
10
- export const RetryProgress = Data.taggedEnum<RetryProgress>()
11
- export const initialRetryProgress: RetryProgress = RetryProgress.Idle()
12
-
13
- interface HintsContext {
14
- readonly filterEditing: boolean
15
- readonly showFilterClear: boolean
16
- readonly detailFullView: boolean
17
- readonly diffFullView: boolean
18
- readonly diffRangeActive: boolean
19
- readonly hasSelection: boolean
20
- readonly canCloseSelection: boolean
21
- readonly hasError: boolean
22
- readonly isLoading: boolean
23
- readonly loadingIndicator: string
24
- readonly retryProgress: RetryProgress
25
- }
26
-
27
- const filterEditingHints: readonly HintItem[] = [
28
- { key: "search", label: "typing" },
29
- { key: "↑↓", label: "move" },
30
- { key: "enter", label: "apply" },
31
- { key: "esc", label: "cancel" },
32
- { key: "ctrl-u", label: "clear" },
33
- { key: "ctrl-w", label: "word" },
34
- ]
35
-
36
- const diffViewHints = (ctx: HintsContext): readonly HintItem[] => [
37
- { key: "esc", label: "back" },
38
- { key: "↑↓", label: ctx.diffRangeActive ? "range" : "line" },
39
- { key: "enter", label: ctx.diffRangeActive ? "comment" : "open" },
40
- { key: "v", label: ctx.diffRangeActive ? "clear" : "range" },
41
- { key: "n/p", label: "threads" },
42
- { key: "[]", label: "files" },
43
- { key: "V", label: "view" },
44
- { key: "w", label: "wrap" },
45
- { key: "r", label: "reload" },
46
- { key: "o", label: "open" },
47
- ]
48
-
49
- const detailFullViewHints = (ctx: HintsContext): readonly HintItem[] => [
50
- { key: "esc", label: "back" },
51
- { key: "↑↓", label: "scroll" },
52
- { key: "r", label: ctx.hasError ? "retry" : "refresh" },
53
- { key: "t", label: "theme" },
54
- { key: "s", label: "state", when: ctx.hasSelection },
55
- { key: "d", label: "diff", when: ctx.hasSelection },
56
- { key: "l", label: "labels", when: ctx.hasSelection },
57
- { key: "m", label: "merge", when: ctx.hasSelection },
58
- { key: "x", label: "close", when: ctx.hasSelection && ctx.canCloseSelection },
59
- { key: "o", label: "open" },
60
- { key: "y", label: "copy" },
61
- { key: "q", label: "quit" },
62
- ]
63
-
64
- const defaultHints = (ctx: HintsContext): readonly HintItem[] => {
65
- const retrying = ctx.retryProgress._tag === "Retrying"
66
- return [
67
- { key: "/", label: "filter" },
68
- { key: "t", label: "theme" },
69
- { key: "esc", label: "clear", when: ctx.showFilterClear },
70
- { key: "retry", label: retrying ? `${(ctx.retryProgress as { attempt: number; max: number }).attempt}/${(ctx.retryProgress as { attempt: number; max: number }).max}` : "", when: retrying, keyFg: colors.status.pending },
71
- { key: ctx.loadingIndicator, label: "loading", when: !retrying && ctx.isLoading, keyFg: colors.status.pending },
72
- { key: "r", label: "retry", when: ctx.hasError },
73
- { key: "d", label: "diff", when: ctx.hasSelection },
74
- { key: "l", label: "labels", when: ctx.hasSelection },
75
- { key: "m", label: "merge", when: ctx.hasSelection },
76
- { key: "x", label: "close", when: ctx.hasSelection && ctx.canCloseSelection },
77
- { key: "o", label: "open", when: ctx.hasSelection },
78
- { key: "y", label: "copy", when: ctx.hasSelection },
79
- { key: "ctrl-p", label: "commands" },
80
- ]
81
- }
82
-
83
- const footerHints = (ctx: HintsContext): readonly HintItem[] => {
84
- if (ctx.filterEditing) return filterEditingHints
85
- if (ctx.diffFullView) return diffViewHints(ctx)
86
- if (ctx.detailFullView) return detailFullViewHints(ctx)
87
- return defaultHints(ctx)
88
- }
89
-
90
- export const FooterHints = (props: HintsContext) => <HintRow items={footerHints(props)} />
@@ -1,75 +0,0 @@
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
- }
@@ -1,249 +0,0 @@
1
- import type { DiffRenderable, MouseEvent, ScrollBoxRenderable } from "@opentui/core"
2
- import { useMemo, type Ref } from "react"
3
- import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
4
- import { colors, lineNumberTextColor, type ThemeId } from "./colors.js"
5
- import { CommentBodyLine, commentCountText, commentMetaSegments, CommentSegmentsLine } from "./comments.js"
6
- import { createDiffSyntaxStyle, diffCommentAnchorLabel, diffCommentLineLabel, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileIndexAtLine, type DiffFileStats, type DiffView, type DiffWhitespaceMode, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
7
- import { LoadingPane, StatusCard } from "./DetailsPane.js"
8
- import { DiffStats } from "./diffStats.js"
9
- import { Divider, fitCell, PaddedRow, PlainLine, TextLine } from "./primitives.js"
10
- import { shortRepoName } from "./pullRequests.js"
11
-
12
- const DiffPaneHeader = ({ pullRequest, paneWidth }: { pullRequest: PullRequestItem; paneWidth: number }) => {
13
- const stats = diffStatText(pullRequest)
14
- const headerWidth = Math.max(24, paneWidth - 2)
15
- const leftHeader = `#${pullRequest.number} ${shortRepoName(pullRequest.repository)}`
16
- const headerGap = Math.max(2, headerWidth - leftHeader.length - stats.length)
17
- return (
18
- <PaddedRow>
19
- <TextLine>
20
- <span fg={colors.count}>#{pullRequest.number}</span>
21
- <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
22
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
23
- <DiffStats pullRequest={pullRequest} />
24
- </TextLine>
25
- </PaddedRow>
26
- )
27
- }
28
-
29
- const FileStats = ({ stats }: { stats: DiffFileStats }) => {
30
- return (
31
- <>
32
- {stats.additions > 0 ? <span fg={colors.status.passing}>{`+${stats.additions}`}</span> : null}
33
- {stats.additions > 0 && stats.deletions > 0 ? <span fg={colors.muted}> </span> : null}
34
- {stats.deletions > 0 ? <span fg={colors.status.failing}>{`-${stats.deletions}`}</span> : null}
35
- </>
36
- )
37
- }
38
-
39
- const FileHeader = ({
40
- file,
41
- index,
42
- count,
43
- width,
44
- suffix = "",
45
- suffixColor = colors.muted,
46
- }: {
47
- file: StackedDiffFilePatch["file"]
48
- index: number
49
- count: number
50
- width: number
51
- suffix?: string
52
- suffixColor?: string
53
- }) => {
54
- const counter = `${index + 1}/${count}`
55
- const stats = diffFileStats(file)
56
- const statsText = diffFileStatsText(stats)
57
- const nameWidth = Math.max(1, width - counter.length - statsText.length - suffix.length - 5)
58
- return (
59
- <TextLine>
60
- <span fg={colors.muted}>{counter} </span>
61
- <span fg={colors.text}>{fitCell(file.name, nameWidth)}</span>
62
- {statsText ? <span fg={colors.muted}> </span> : null}
63
- <FileStats stats={stats} />
64
- {suffix ? <span fg={suffixColor}>{suffix}</span> : null}
65
- </TextLine>
66
- )
67
- }
68
-
69
- export const PullRequestDiffPane = ({
70
- pullRequest,
71
- diffState,
72
- stackedFiles,
73
- scrollTop,
74
- view,
75
- whitespaceMode,
76
- wrapMode,
77
- paneWidth,
78
- height,
79
- loadingIndicator,
80
- scrollRef,
81
- setDiffRef,
82
- selectedCommentAnchor,
83
- selectedCommentLabel,
84
- selectedCommentThread,
85
- onSelectCommentLine,
86
- themeId,
87
- }: {
88
- pullRequest: PullRequestItem | null
89
- diffState: PullRequestDiffState | undefined
90
- stackedFiles: readonly StackedDiffFilePatch[]
91
- scrollTop: number
92
- view: DiffView
93
- whitespaceMode: DiffWhitespaceMode
94
- wrapMode: DiffWrapMode
95
- paneWidth: number
96
- height: number
97
- loadingIndicator: string
98
- scrollRef: Ref<ScrollBoxRenderable>
99
- setDiffRef: (index: number, diff: DiffRenderable | null) => void
100
- selectedCommentAnchor: StackedDiffCommentAnchor | null
101
- selectedCommentLabel: string | null
102
- selectedCommentThread: readonly PullRequestReviewComment[]
103
- onSelectCommentLine: (renderLine: number, side: DiffCommentSide | null) => void
104
- themeId: ThemeId
105
- }) => {
106
- const readyFiles = diffState?._tag === "Ready" ? diffState.files : []
107
- const syntaxStyle = useMemo(() => createDiffSyntaxStyle(), [themeId])
108
-
109
- if (!pullRequest) {
110
- return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
111
- }
112
-
113
- if (!diffState || diffState._tag === "Loading") {
114
- return (
115
- <box height={height} flexDirection="column">
116
- <DiffPaneHeader pullRequest={pullRequest} paneWidth={paneWidth} />
117
- <Divider width={paneWidth} />
118
- <LoadingPane content={{ title: `${loadingIndicator} Loading diff`, hint: "Fetching patch from GitHub" }} width={paneWidth} height={Math.max(1, height - 2)} />
119
- </box>
120
- )
121
- }
122
-
123
- if (diffState._tag === "Error") {
124
- return (
125
- <box height={height} flexDirection="column">
126
- <PaddedRow>
127
- <PlainLine text={`#${pullRequest.number} ${shortRepoName(pullRequest.repository)} diff`} fg={colors.count} bold />
128
- </PaddedRow>
129
- <Divider width={paneWidth} />
130
- <StatusCard content={{ title: "Could not load diff", hint: diffState.error }} width={paneWidth} />
131
- </box>
132
- )
133
- }
134
-
135
- if (readyFiles.length === 0 || stackedFiles.length === 0) {
136
- return <LoadingPane content={{ title: whitespaceMode === "ignore" ? "No non-whitespace diff" : "No diff", hint: whitespaceMode === "ignore" ? "Use the command palette to show whitespace changes" : "This PR has no patch contents" }} width={paneWidth} height={height} />
137
- }
138
-
139
- const hasSelectedCommentAnchor = selectedCommentAnchor !== null
140
- const commentPeek = hasSelectedCommentAnchor && selectedCommentThread.length > 0
141
- ? selectedCommentThread[selectedCommentThread.length - 1]!
142
- : null
143
- const commentPeekMeta = commentPeek && selectedCommentAnchor
144
- ? commentMetaSegments({
145
- item: commentPeek,
146
- markerLabel: diffCommentLineLabel(selectedCommentAnchor),
147
- groups: [
148
- [{ text: commentCountText(selectedCommentThread.length), fg: colors.muted }],
149
- [{ text: "enter", fg: colors.text }, { text: " thread", fg: colors.muted }],
150
- ],
151
- })
152
- : []
153
- const stickyScrollTop = Math.max(0, Math.floor(scrollTop))
154
- const stickyArrayIndex = stackedDiffFileIndexAtLine(stackedFiles, stickyScrollTop)
155
- const stickyFile = stickyArrayIndex >= 0 ? stackedFiles[stickyArrayIndex] : stackedFiles[0]
156
- const incomingStickyFile = stickyArrayIndex >= 0 ? stackedFiles[stickyArrayIndex + 1] : undefined
157
- const incomingHeaderDistance = incomingStickyFile ? incomingStickyFile.headerLine - stickyScrollTop : Number.POSITIVE_INFINITY
158
- const incomingFile = incomingHeaderDistance === 1 ? incomingStickyFile : undefined
159
- const stickyCommentLabelFor = (stackedFile: StackedDiffFilePatch | undefined) => {
160
- if (!selectedCommentAnchor) return " no lines"
161
- if (selectedCommentAnchor.fileIndex !== stackedFile?.index) return ""
162
- return ` ${selectedCommentLabel ?? diffCommentAnchorLabel(selectedCommentAnchor)}`
163
- }
164
- const stickyCommentColor = selectedCommentAnchor?.side === "LEFT" ? colors.status.failing : colors.status.passing
165
- const diffLineNumberFg = lineNumberTextColor(colors.diff.lineNumberBg, colors.text)
166
- const handleDiffMouseDown = function (this: ScrollBoxRenderable, event: MouseEvent) {
167
- if (event.button !== 0) return
168
- const localY = event.y - this.viewport.y
169
- if (localY < 0 || localY >= this.viewport.height) return
170
- const localX = event.x - this.viewport.x
171
- const side = view === "split"
172
- ? localX < Math.floor(paneWidth / 2) ? "LEFT" : "RIGHT"
173
- : null
174
- onSelectCommentLine(Math.max(0, Math.floor(this.scrollTop + localY)), side)
175
- event.preventDefault()
176
- event.stopPropagation()
177
- }
178
-
179
- return (
180
- <box height={height} flexDirection="column">
181
- <DiffPaneHeader pullRequest={pullRequest} paneWidth={paneWidth} />
182
- <Divider width={paneWidth} />
183
- <scrollbox ref={scrollRef} focusable={false} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
184
- {stackedFiles.map((stackedFile) => (
185
- <box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
186
- {stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
187
- <PaddedRow>
188
- <FileHeader file={stackedFile.file} index={stackedFile.index} count={readyFiles.length} width={paneWidth} />
189
- </PaddedRow>
190
- <Divider width={paneWidth} />
191
- <diff
192
- ref={(diff: DiffRenderable | null) => setDiffRef(stackedFile.index, diff)}
193
- diff={stackedFile.file.patch}
194
- view={view}
195
- syncScroll
196
- filetype={stackedFile.file.filetype ?? "text"}
197
- syntaxStyle={syntaxStyle}
198
- showLineNumbers
199
- wrapMode={wrapMode}
200
- addedBg={colors.diff.addedBg}
201
- removedBg={colors.diff.removedBg}
202
- contextBg={colors.diff.contextBg}
203
- addedSignColor={colors.status.passing}
204
- removedSignColor={colors.status.failing}
205
- lineNumberFg={diffLineNumberFg}
206
- lineNumberBg={colors.diff.lineNumberBg}
207
- addedLineNumberBg={colors.diff.addedLineNumberBg}
208
- removedLineNumberBg={colors.diff.removedLineNumberBg}
209
- selectionBg={colors.selectedBg}
210
- selectionFg={colors.selectedText}
211
- height={stackedFile.diffHeight}
212
- style={{ flexShrink: 0 }}
213
- />
214
- </box>
215
- ))}
216
- </scrollbox>
217
- {stickyFile ? (
218
- <box position="absolute" top={2} left={0} width={paneWidth} height={2} zIndex={10} flexDirection="column" backgroundColor={colors.background}>
219
- {incomingFile ? (
220
- <>
221
- <Divider width={paneWidth} />
222
- <PaddedRow backgroundColor={colors.background}>
223
- <FileHeader file={incomingFile.file} index={incomingFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(incomingFile)} suffixColor={stickyCommentColor} />
224
- </PaddedRow>
225
- </>
226
- ) : (
227
- <>
228
- <PaddedRow backgroundColor={colors.background}>
229
- <FileHeader file={stickyFile.file} index={stickyFile.index} count={readyFiles.length} width={paneWidth} suffix={stickyCommentLabelFor(stickyFile)} suffixColor={stickyCommentColor} />
230
- </PaddedRow>
231
- <Divider width={paneWidth} />
232
- </>
233
- )}
234
- </box>
235
- ) : null}
236
- {commentPeek ? (
237
- <>
238
- <Divider width={paneWidth} />
239
- <PaddedRow>
240
- <CommentSegmentsLine segments={commentPeekMeta} />
241
- </PaddedRow>
242
- <PaddedRow>
243
- <CommentBodyLine body={commentPeek.body} width={Math.max(1, paneWidth - 2)} />
244
- </PaddedRow>
245
- </>
246
- ) : null}
247
- </box>
248
- )
249
- }
@@ -1,194 +0,0 @@
1
- import { TextAttributes } from "@opentui/core"
2
- import type { LoadStatus, PullRequestItem } from "../domain.js"
3
- import { daysOpen } from "../date.js"
4
- import { colors } from "./colors.js"
5
- import { fitCell, MatchedCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
6
- import { pullRequestRowDisplay, repoColor, reviewIcon } from "./pullRequests.js"
7
-
8
- export type PullRequestGroups = Array<[string, PullRequestItem[]]>
9
-
10
- export type PullRequestListRow =
11
- | { readonly _tag: "title" }
12
- | { readonly _tag: "filter" }
13
- | { readonly _tag: "message"; readonly text: string; readonly color: string }
14
- | { readonly _tag: "group"; readonly repository: string; readonly pullRequests: readonly PullRequestItem[] }
15
- | { readonly _tag: "pull-request"; readonly pullRequest: PullRequestItem; readonly groupPullRequests: readonly PullRequestItem[] }
16
- | { readonly _tag: "load-more"; readonly text: string }
17
-
18
- const GROUP_ICON = "◆"
19
-
20
- const getRowLayout = (contentWidth: number, numberWidth: number, ageWidth: number) => {
21
- const reviewWidth = 1
22
- const checkWidth = 6
23
- const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth
24
- const titleWidth = Math.max(8, contentWidth - fixedWidth)
25
- return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
26
- }
27
-
28
- const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
29
- if (pullRequests.length === 0) return 4
30
- const maxLen = Math.max(...pullRequests.map((pr) => String(pr.number).length))
31
- return maxLen + 1
32
- }
33
-
34
- const groupAgeWidth = (pullRequests: readonly PullRequestItem[]) => {
35
- if (pullRequests.length === 0) return 4
36
- const maxLen = Math.max(...pullRequests.map((pr) => `${daysOpen(pr.createdAt)}d`.length))
37
- return Math.max(4, maxLen + 1)
38
- }
39
-
40
- const GroupTitle = ({ label, color, filterText }: { label: string; color: string; filterText: string }) => (
41
- <TextLine>
42
- <span fg={color}>{GROUP_ICON} </span>
43
- <span fg={color} attributes={TextAttributes.BOLD}><MatchedCell text={label} width={label.length} query={filterText} /></span>
44
- </TextLine>
45
- )
46
-
47
- export const buildPullRequestListRows = ({
48
- groups,
49
- status,
50
- error,
51
- filterText,
52
- showFilterBar,
53
- loadedCount,
54
- hasMore,
55
- isLoadingMore,
56
- loadingIndicator = "-",
57
- }: {
58
- readonly groups: PullRequestGroups
59
- readonly status: LoadStatus
60
- readonly error: string | null
61
- readonly filterText: string
62
- readonly showFilterBar: boolean
63
- readonly loadedCount: number
64
- readonly hasMore: boolean
65
- readonly isLoadingMore: boolean
66
- readonly loadingIndicator?: string
67
- }): readonly PullRequestListRow[] => {
68
- const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
69
- const rows: PullRequestListRow[] = [{ _tag: "title" }]
70
- if (showFilterBar) rows.push({ _tag: "filter" })
71
- if (status === "loading" && itemCount === 0) rows.push({ _tag: "message", text: "- Loading pull requests...", color: colors.muted })
72
- if (status === "error") rows.push({ _tag: "message", text: `- ${error ?? "Could not load pull requests."}`, color: colors.error })
73
- if (status === "ready" && itemCount === 0) rows.push({ _tag: "message", text: filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests.", color: colors.muted })
74
- for (const [repository, pullRequests] of groups) {
75
- rows.push({ _tag: "group", repository, pullRequests })
76
- for (const pullRequest of pullRequests) rows.push({ _tag: "pull-request", pullRequest, groupPullRequests: pullRequests })
77
- }
78
- if (status === "ready" && itemCount > 0 && (hasMore || isLoadingMore)) {
79
- rows.push({ _tag: "load-more", text: isLoadingMore ? `${loadingIndicator} Loading more pull requests... (${loadedCount} loaded)` : `- ${loadedCount} loaded, more available` })
80
- }
81
- return rows
82
- }
83
-
84
- export const pullRequestListRowIndex = (rows: readonly PullRequestListRow[], url: string | null) => {
85
- if (!url) return null
86
- const index = rows.findIndex((row) => row._tag === "pull-request" && row.pullRequest.url === url)
87
- return index >= 0 ? index : null
88
- }
89
-
90
- const PullRequestRow = ({
91
- pullRequest,
92
- selected,
93
- contentWidth,
94
- numWidth,
95
- ageColWidth,
96
- filterText,
97
- onSelect,
98
- }: {
99
- pullRequest: PullRequestItem
100
- selected: boolean
101
- contentWidth: number
102
- numWidth: number
103
- ageColWidth: number
104
- filterText: string
105
- onSelect: () => void
106
- }) => {
107
- const ageText = `${daysOpen(pullRequest.createdAt)}d`
108
- const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth, ageColWidth)
109
- const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
110
- const fillerWidth = Math.max(0, contentWidth - rowWidth)
111
- const display = pullRequestRowDisplay(pullRequest, selected)
112
-
113
- return (
114
- <box width={contentWidth} height={1} onMouseDown={onSelect}>
115
- <TextLine width={contentWidth} fg={display.rowFg} bg={selected ? colors.selectedBg : undefined}>
116
- <span fg={display.indicatorFg}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
117
- <span> </span>
118
- <span fg={display.numberFg}><MatchedCell text={`#${pullRequest.number}`} width={numberWidth} query={filterText} align="right" /></span>
119
- <span> </span>
120
- <span><MatchedCell text={pullRequest.title} width={titleWidth} query={filterText} /></span>
121
- <span fg={display.checkFg}>{fitCell(display.checkText, checkWidth, "right")}</span>
122
- <span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
123
- {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
124
- </TextLine>
125
- </box>
126
- )
127
- }
128
-
129
- export const PullRequestList = ({
130
- groups,
131
- selectedUrl,
132
- status,
133
- error,
134
- contentWidth,
135
- filterText,
136
- showFilterBar,
137
- isFilterEditing,
138
- loadedCount,
139
- hasMore,
140
- isLoadingMore,
141
- loadingIndicator,
142
- onSelectPullRequest,
143
- }: {
144
- groups: PullRequestGroups
145
- selectedUrl: string | null
146
- status: LoadStatus
147
- error: string | null
148
- contentWidth: number
149
- filterText: string
150
- showFilterBar: boolean
151
- isFilterEditing: boolean
152
- loadedCount: number
153
- hasMore: boolean
154
- isLoadingMore: boolean
155
- loadingIndicator: string
156
- onSelectPullRequest: (url: string) => void
157
- }) => {
158
- const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, loadedCount, hasMore, isLoadingMore, loadingIndicator })
159
-
160
- return (
161
- <box width={contentWidth} flexDirection="column">
162
- {rows.map((row, index) => {
163
- if (row._tag === "title") return <SectionTitle key="title" title="PULL REQUESTS" />
164
- if (row._tag === "filter") {
165
- return (
166
- <TextLine key="filter">
167
- <span fg={colors.count}>/</span>
168
- <span fg={colors.muted}> </span>
169
- <span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
170
- </TextLine>
171
- )
172
- }
173
- if (row._tag === "message") return <PlainLine key={`message-${index}`} text={row.text} fg={row.color} />
174
- if (row._tag === "load-more") return <PlainLine key="load-more" text={row.text} fg={colors.muted} />
175
- if (row._tag === "group") return <GroupTitle key={`group-${row.repository}`} label={row.repository} color={repoColor(row.repository)} filterText={filterText} />
176
-
177
- const numWidth = groupNumberWidth(row.groupPullRequests)
178
- const ageColWidth = groupAgeWidth(row.groupPullRequests)
179
- return (
180
- <PullRequestRow
181
- key={row.pullRequest.url}
182
- pullRequest={row.pullRequest}
183
- selected={row.pullRequest.url === selectedUrl}
184
- contentWidth={contentWidth}
185
- numWidth={numWidth}
186
- ageColWidth={ageColWidth}
187
- filterText={filterText}
188
- onSelect={() => onSelectPullRequest(row.pullRequest.url)}
189
- />
190
- )
191
- })}
192
- </box>
193
- )
194
- }