@kitlangton/ghui 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/package.json +2 -1
- package/src/App.tsx +983 -238
- package/src/domain.ts +77 -10
- package/src/services/CommandRunner.ts +8 -1
- package/src/services/GitHubService.ts +291 -193
- package/src/ui/DetailsPane.tsx +26 -25
- package/src/ui/FooterHints.tsx +62 -6
- package/src/ui/PullRequestDiffPane.tsx +160 -49
- package/src/ui/PullRequestList.tsx +28 -10
- package/src/ui/colors.ts +41 -0
- package/src/ui/commentEditor.ts +126 -0
- package/src/ui/diff.ts +229 -16
- package/src/ui/modals.tsx +236 -9
- package/src/ui/primitives.tsx +2 -2
package/src/ui/modals.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
|
-
import
|
|
2
|
+
import { Data } from "effect"
|
|
3
|
+
import { formatShortDate, formatTimestamp } from "../date.js"
|
|
4
|
+
import type { PullRequestLabel, PullRequestMergeInfo, PullRequestReviewComment } from "../domain.js"
|
|
3
5
|
import { availableMergeActions } from "../mergeActions.js"
|
|
6
|
+
import { clampCursor, commentEditorLines, cursorLineIndexForLines } from "./commentEditor.js"
|
|
4
7
|
import { colors, filterThemeDefinitions, themeDefinitions, type ThemeId } from "./colors.js"
|
|
5
8
|
import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
|
|
6
9
|
import { labelColor, shortRepoName } from "./pullRequests.js"
|
|
7
10
|
|
|
8
11
|
export interface LabelModalState {
|
|
9
|
-
readonly open: boolean
|
|
10
12
|
readonly repository: string | null
|
|
11
13
|
readonly query: string
|
|
12
14
|
readonly selectedIndex: number
|
|
@@ -15,7 +17,6 @@ export interface LabelModalState {
|
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface MergeModalState {
|
|
18
|
-
readonly open: boolean
|
|
19
20
|
readonly repository: string | null
|
|
20
21
|
readonly number: number | null
|
|
21
22
|
readonly selectedIndex: number
|
|
@@ -26,7 +27,6 @@ export interface MergeModalState {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export interface CloseModalState {
|
|
29
|
-
readonly open: boolean
|
|
30
30
|
readonly repository: string | null
|
|
31
31
|
readonly number: number | null
|
|
32
32
|
readonly title: string
|
|
@@ -35,15 +35,23 @@ export interface CloseModalState {
|
|
|
35
35
|
readonly error: string | null
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
export interface CommentModalState {
|
|
39
|
+
readonly body: string
|
|
40
|
+
readonly cursor: number
|
|
41
|
+
readonly error: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CommentThreadModalState {
|
|
45
|
+
readonly scrollOffset: number
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
export interface ThemeModalState {
|
|
39
|
-
readonly open: boolean
|
|
40
49
|
readonly query: string
|
|
41
50
|
readonly filterMode: boolean
|
|
42
51
|
readonly initialThemeId: ThemeId
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
export const initialLabelModalState: LabelModalState = {
|
|
46
|
-
open: false,
|
|
47
55
|
repository: null,
|
|
48
56
|
query: "",
|
|
49
57
|
selectedIndex: 0,
|
|
@@ -52,7 +60,6 @@ export const initialLabelModalState: LabelModalState = {
|
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
export const initialMergeModalState: MergeModalState = {
|
|
55
|
-
open: false,
|
|
56
63
|
repository: null,
|
|
57
64
|
number: null,
|
|
58
65
|
selectedIndex: 0,
|
|
@@ -63,7 +70,6 @@ export const initialMergeModalState: MergeModalState = {
|
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
export const initialCloseModalState: CloseModalState = {
|
|
66
|
-
open: false,
|
|
67
73
|
repository: null,
|
|
68
74
|
number: null,
|
|
69
75
|
title: "",
|
|
@@ -72,13 +78,47 @@ export const initialCloseModalState: CloseModalState = {
|
|
|
72
78
|
error: null,
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
export const initialCommentModalState: CommentModalState = {
|
|
82
|
+
body: "",
|
|
83
|
+
cursor: 0,
|
|
84
|
+
error: null,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const initialCommentThreadModalState: CommentThreadModalState = {
|
|
88
|
+
scrollOffset: 0,
|
|
89
|
+
}
|
|
90
|
+
|
|
75
91
|
export const initialThemeModalState: ThemeModalState = {
|
|
76
|
-
open: false,
|
|
77
92
|
query: "",
|
|
78
93
|
filterMode: false,
|
|
79
94
|
initialThemeId: "ghui",
|
|
80
95
|
}
|
|
81
96
|
|
|
97
|
+
export type Modal = Data.TaggedEnum<{
|
|
98
|
+
None: {}
|
|
99
|
+
Label: LabelModalState
|
|
100
|
+
Close: CloseModalState
|
|
101
|
+
Merge: MergeModalState
|
|
102
|
+
Comment: CommentModalState
|
|
103
|
+
CommentThread: CommentThreadModalState
|
|
104
|
+
Theme: ThemeModalState
|
|
105
|
+
}>
|
|
106
|
+
|
|
107
|
+
export const Modal = Data.taggedEnum<Modal>()
|
|
108
|
+
export const initialModal: Modal = Modal.None()
|
|
109
|
+
|
|
110
|
+
export type ModalTag = Modal["_tag"]
|
|
111
|
+
export type ModalState<Tag extends Exclude<ModalTag, "None">> = Omit<Extract<Modal, { _tag: Tag }>, "_tag">
|
|
112
|
+
|
|
113
|
+
export const modalInitialStates = {
|
|
114
|
+
Label: initialLabelModalState,
|
|
115
|
+
Close: initialCloseModalState,
|
|
116
|
+
Merge: initialMergeModalState,
|
|
117
|
+
Comment: initialCommentModalState,
|
|
118
|
+
CommentThread: initialCommentThreadModalState,
|
|
119
|
+
Theme: initialThemeModalState,
|
|
120
|
+
} as const satisfies { [Tag in Exclude<ModalTag, "None">]: ModalState<Tag> }
|
|
121
|
+
|
|
82
122
|
const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
|
|
83
123
|
if (!info) return "Loading merge status from GitHub."
|
|
84
124
|
if (info.state !== "open") return "This pull request is not open."
|
|
@@ -349,6 +389,193 @@ export const CloseModal = ({
|
|
|
349
389
|
)
|
|
350
390
|
}
|
|
351
391
|
|
|
392
|
+
export const CommentModal = ({
|
|
393
|
+
state,
|
|
394
|
+
anchorLabel,
|
|
395
|
+
modalWidth,
|
|
396
|
+
modalHeight,
|
|
397
|
+
offsetLeft,
|
|
398
|
+
offsetTop,
|
|
399
|
+
}: {
|
|
400
|
+
state: CommentModalState
|
|
401
|
+
anchorLabel: string
|
|
402
|
+
modalWidth: number
|
|
403
|
+
modalHeight: number
|
|
404
|
+
offsetLeft: number
|
|
405
|
+
offsetTop: number
|
|
406
|
+
}) => {
|
|
407
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
408
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
409
|
+
const title = "Comment"
|
|
410
|
+
const rightText = "enter save"
|
|
411
|
+
const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
|
|
412
|
+
const bodyHeight = Math.max(1, modalHeight - 7)
|
|
413
|
+
const editorHeight = Math.max(1, bodyHeight - (state.error ? 1 : 0))
|
|
414
|
+
const lineRanges = commentEditorLines(state.body)
|
|
415
|
+
const cursor = clampCursor(state.body, state.cursor)
|
|
416
|
+
const cursorLineIndex = cursorLineIndexForLines(lineRanges, cursor)
|
|
417
|
+
const visibleStart = Math.min(
|
|
418
|
+
Math.max(0, lineRanges.length - editorHeight),
|
|
419
|
+
Math.max(0, cursorLineIndex - editorHeight + 1),
|
|
420
|
+
)
|
|
421
|
+
const visibleLines = lineRanges.slice(visibleStart, visibleStart + editorHeight)
|
|
422
|
+
const renderEditorLine = (line: { readonly text: string; readonly start: number; readonly end: number }, index: number) => {
|
|
423
|
+
const lineIndex = visibleStart + index
|
|
424
|
+
const isCursorLine = lineIndex === cursorLineIndex
|
|
425
|
+
const cursorColumn = Math.max(0, Math.min(cursor - line.start, line.text.length))
|
|
426
|
+
const viewStart = isCursorLine ? Math.max(0, cursorColumn - contentWidth + 1) : 0
|
|
427
|
+
const visibleText = line.text.slice(viewStart, viewStart + contentWidth)
|
|
428
|
+
|
|
429
|
+
if (!isCursorLine) {
|
|
430
|
+
return <PlainLine key={lineIndex} text={fitCell(visibleText, contentWidth)} fg={state.body.length > 0 ? colors.text : colors.muted} />
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const cursorInView = cursorColumn - viewStart
|
|
434
|
+
const before = visibleText.slice(0, cursorInView)
|
|
435
|
+
const placeholder = state.body.length === 0 ? "Write a comment..." : ""
|
|
436
|
+
const cursorChar = placeholder ? placeholder[0] ?? " " : visibleText[cursorInView] ?? " "
|
|
437
|
+
const after = placeholder ? placeholder.slice(1) : visibleText.slice(cursorInView + 1)
|
|
438
|
+
|
|
439
|
+
return (
|
|
440
|
+
<TextLine key={lineIndex}>
|
|
441
|
+
{before ? <span fg={colors.text}>{before}</span> : null}
|
|
442
|
+
<span bg={colors.accent} fg={colors.background}>{cursorChar}</span>
|
|
443
|
+
{after ? <span fg={placeholder ? colors.muted : colors.text}>{after}</span> : null}
|
|
444
|
+
</TextLine>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
450
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
451
|
+
<TextLine>
|
|
452
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
453
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
454
|
+
<span fg={colors.muted}>{rightText}</span>
|
|
455
|
+
</TextLine>
|
|
456
|
+
</box>
|
|
457
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
458
|
+
<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />
|
|
459
|
+
</box>
|
|
460
|
+
<Divider width={innerWidth} />
|
|
461
|
+
<box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
462
|
+
{state.error ? <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} /> : null}
|
|
463
|
+
{visibleLines.map(renderEditorLine)}
|
|
464
|
+
</box>
|
|
465
|
+
<Divider width={innerWidth} />
|
|
466
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
467
|
+
<TextLine>
|
|
468
|
+
<span fg={colors.count}>enter</span>
|
|
469
|
+
<span fg={colors.muted}> save </span>
|
|
470
|
+
<span fg={colors.count}>shift-enter</span>
|
|
471
|
+
<span fg={colors.muted}> newline </span>
|
|
472
|
+
<span fg={colors.count}>esc</span>
|
|
473
|
+
<span fg={colors.muted}> cancel</span>
|
|
474
|
+
</TextLine>
|
|
475
|
+
</box>
|
|
476
|
+
</ModalFrame>
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
type CommentThreadRow = {
|
|
481
|
+
readonly key: string
|
|
482
|
+
readonly text: string
|
|
483
|
+
readonly fg: string
|
|
484
|
+
readonly bold?: boolean
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const wrapCommentBody = (body: string, width: number) => {
|
|
488
|
+
const lines = body.length === 0 ? [""] : body.split("\n")
|
|
489
|
+
return lines.flatMap((line) => {
|
|
490
|
+
if (line.length === 0) return [""]
|
|
491
|
+
const wrapped: string[] = []
|
|
492
|
+
for (let index = 0; index < line.length; index += width) {
|
|
493
|
+
wrapped.push(line.slice(index, index + width))
|
|
494
|
+
}
|
|
495
|
+
return wrapped
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const formatCommentDate = (date: Date | null) => date ? `${formatShortDate(date)} ${formatTimestamp(date)}` : ""
|
|
500
|
+
|
|
501
|
+
const commentThreadRows = (comments: readonly PullRequestReviewComment[], width: number): readonly CommentThreadRow[] =>
|
|
502
|
+
comments.flatMap((comment, commentIndex) => {
|
|
503
|
+
const timestamp = formatCommentDate(comment.createdAt)
|
|
504
|
+
const header = timestamp ? `${comment.author} ${timestamp}` : comment.author
|
|
505
|
+
return [
|
|
506
|
+
{ key: `${comment.id}:header`, text: header, fg: colors.count, bold: true },
|
|
507
|
+
...wrapCommentBody(comment.body, width).map((line, lineIndex) => ({
|
|
508
|
+
key: `${comment.id}:body:${lineIndex}`,
|
|
509
|
+
text: line,
|
|
510
|
+
fg: colors.text,
|
|
511
|
+
})),
|
|
512
|
+
...(commentIndex < comments.length - 1 ? [{ key: `${comment.id}:gap`, text: "", fg: colors.muted }] : []),
|
|
513
|
+
]
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
export const CommentThreadModal = ({
|
|
517
|
+
state,
|
|
518
|
+
anchorLabel,
|
|
519
|
+
comments,
|
|
520
|
+
modalWidth,
|
|
521
|
+
modalHeight,
|
|
522
|
+
offsetLeft,
|
|
523
|
+
offsetTop,
|
|
524
|
+
}: {
|
|
525
|
+
state: CommentThreadModalState
|
|
526
|
+
anchorLabel: string
|
|
527
|
+
comments: readonly PullRequestReviewComment[]
|
|
528
|
+
modalWidth: number
|
|
529
|
+
modalHeight: number
|
|
530
|
+
offsetLeft: number
|
|
531
|
+
offsetTop: number
|
|
532
|
+
}) => {
|
|
533
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
534
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
535
|
+
const title = "Thread"
|
|
536
|
+
const countText = comments.length === 1 ? "1 comment" : `${comments.length} comments`
|
|
537
|
+
const headerGap = Math.max(1, contentWidth - title.length - countText.length)
|
|
538
|
+
const bodyHeight = Math.max(1, modalHeight - 7)
|
|
539
|
+
const rows = commentThreadRows(comments, contentWidth)
|
|
540
|
+
const maxScroll = Math.max(0, rows.length - bodyHeight)
|
|
541
|
+
const scrollOffset = Math.max(0, Math.min(state.scrollOffset, maxScroll))
|
|
542
|
+
const visibleRows = rows.slice(scrollOffset, scrollOffset + bodyHeight)
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
546
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
547
|
+
<TextLine>
|
|
548
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
549
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
550
|
+
<span fg={colors.muted}>{countText}</span>
|
|
551
|
+
</TextLine>
|
|
552
|
+
</box>
|
|
553
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
554
|
+
<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />
|
|
555
|
+
</box>
|
|
556
|
+
<Divider width={innerWidth} />
|
|
557
|
+
<box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
558
|
+
{visibleRows.length === 0 ? (
|
|
559
|
+
<PlainLine text={fitCell("No comments on this line.", contentWidth)} fg={colors.muted} />
|
|
560
|
+
) : visibleRows.map((row) => (
|
|
561
|
+
<PlainLine key={row.key} text={fitCell(row.text, contentWidth)} fg={row.fg} bold={row.bold ?? false} />
|
|
562
|
+
))}
|
|
563
|
+
</box>
|
|
564
|
+
<Divider width={innerWidth} />
|
|
565
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
566
|
+
<TextLine>
|
|
567
|
+
<span fg={colors.count}>↑↓</span>
|
|
568
|
+
<span fg={colors.muted}> scroll </span>
|
|
569
|
+
<span fg={colors.count}>a</span>
|
|
570
|
+
<span fg={colors.muted}> comment </span>
|
|
571
|
+
<span fg={colors.count}>esc</span>
|
|
572
|
+
<span fg={colors.muted}> close</span>
|
|
573
|
+
</TextLine>
|
|
574
|
+
</box>
|
|
575
|
+
</ModalFrame>
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
352
579
|
export const ThemeModal = ({
|
|
353
580
|
state,
|
|
354
581
|
activeThemeId,
|
package/src/ui/primitives.tsx
CHANGED
|
@@ -29,8 +29,8 @@ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: stri
|
|
|
29
29
|
</box>
|
|
30
30
|
)
|
|
31
31
|
|
|
32
|
-
export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
|
|
33
|
-
<box height={1}>
|
|
32
|
+
export const TextLine = ({ children, fg = colors.text, bg, width }: { children: React.ReactNode; fg?: string; bg?: string | undefined; width?: number }) => (
|
|
33
|
+
<box height={1} {...(width === undefined ? {} : { width })}>
|
|
34
34
|
{bg ? (
|
|
35
35
|
<text wrapMode="none" truncate fg={fg} bg={bg}>
|
|
36
36
|
{children}
|