@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/src/ui/modals.tsx CHANGED
@@ -1,12 +1,14 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
- import type { PullRequestLabel, PullRequestMergeInfo } from "../domain.js"
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,
@@ -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}