@kitlangton/ghui 0.1.16 → 0.1.18

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
@@ -25,15 +26,32 @@ export interface MergeModalState {
25
26
  readonly error: string | null
26
27
  }
27
28
 
29
+ export interface CloseModalState {
30
+ readonly repository: string | null
31
+ readonly number: number | null
32
+ readonly title: string
33
+ readonly url: string | null
34
+ readonly running: boolean
35
+ readonly error: string | null
36
+ }
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
+
28
48
  export interface ThemeModalState {
29
- readonly open: boolean
30
49
  readonly query: string
31
50
  readonly filterMode: boolean
32
51
  readonly initialThemeId: ThemeId
33
52
  }
34
53
 
35
54
  export const initialLabelModalState: LabelModalState = {
36
- open: false,
37
55
  repository: null,
38
56
  query: "",
39
57
  selectedIndex: 0,
@@ -42,7 +60,6 @@ export const initialLabelModalState: LabelModalState = {
42
60
  }
43
61
 
44
62
  export const initialMergeModalState: MergeModalState = {
45
- open: false,
46
63
  repository: null,
47
64
  number: null,
48
65
  selectedIndex: 0,
@@ -52,13 +69,56 @@ export const initialMergeModalState: MergeModalState = {
52
69
  error: null,
53
70
  }
54
71
 
72
+ export const initialCloseModalState: CloseModalState = {
73
+ repository: null,
74
+ number: null,
75
+ title: "",
76
+ url: null,
77
+ running: false,
78
+ error: null,
79
+ }
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
+
55
91
  export const initialThemeModalState: ThemeModalState = {
56
- open: false,
57
92
  query: "",
58
93
  filterMode: false,
59
94
  initialThemeId: "ghui",
60
95
  }
61
96
 
97
+ export type Modal = Data.TaggedEnum<{
98
+ None: {}
99
+ Label: { readonly state: LabelModalState }
100
+ Close: { readonly state: CloseModalState }
101
+ Merge: { readonly state: MergeModalState }
102
+ Comment: { readonly state: CommentModalState }
103
+ CommentThread: { readonly state: CommentThreadModalState }
104
+ Theme: { readonly state: 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">> = Extract<Modal, { _tag: Tag }>["state"]
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
+
62
122
  const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
63
123
  if (!info) return "Loading merge status from GitHub."
64
124
  if (info.state !== "open") return "This pull request is not open."
@@ -191,7 +251,7 @@ export const MergeModal = ({
191
251
  const options = availableMergeActions(state.info)
192
252
  const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
193
253
  const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
194
- const rightText = state.running ? "running" : state.loading ? "loading" : state.info?.autoMergeEnabled ? "auto on" : "manual"
254
+ const rightText = state.running ? `${loadingIndicator} running` : state.loading ? `${loadingIndicator} loading` : state.info?.autoMergeEnabled ? "auto on" : "manual"
195
255
  const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
196
256
  const repo = state.info?.repository ?? state.repository
197
257
  const statusLine = state.info
@@ -262,6 +322,260 @@ export const MergeModal = ({
262
322
  )
263
323
  }
264
324
 
325
+ export const CloseModal = ({
326
+ state,
327
+ modalWidth,
328
+ modalHeight,
329
+ offsetLeft,
330
+ offsetTop,
331
+ loadingIndicator,
332
+ }: {
333
+ state: CloseModalState
334
+ modalWidth: number
335
+ modalHeight: number
336
+ offsetLeft: number
337
+ offsetTop: number
338
+ loadingIndicator: string
339
+ }) => {
340
+ const innerWidth = Math.max(16, modalWidth - 2)
341
+ const contentWidth = Math.max(14, innerWidth - 2)
342
+ const title = state.number ? `Close #${state.number}` : "Close pull request"
343
+ const rightText = state.running ? `${loadingIndicator} closing` : "confirm"
344
+ const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
345
+ const repo = state.repository ? shortRepoName(state.repository) : ""
346
+ const titleLines = [
347
+ fitCell(repo, contentWidth),
348
+ fitCell(state.title, contentWidth),
349
+ ]
350
+ const bodyHeight = Math.max(1, modalHeight - 7)
351
+ const topRows = Math.max(0, Math.floor((bodyHeight - titleLines.length - 2) / 2))
352
+ const bottomRows = Math.max(0, bodyHeight - topRows - titleLines.length - 2)
353
+
354
+ return (
355
+ <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
356
+ <box height={1} paddingLeft={1} paddingRight={1}>
357
+ <TextLine>
358
+ <span fg={colors.error} attributes={TextAttributes.BOLD}>{title}</span>
359
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
360
+ <span fg={state.running ? colors.status.pending : colors.muted}>{rightText}</span>
361
+ </TextLine>
362
+ </box>
363
+ <box height={1} paddingLeft={1} paddingRight={1}>
364
+ <PlainLine text={fitCell("This will close the pull request without merging it.", contentWidth)} fg={colors.muted} />
365
+ </box>
366
+ <Divider width={innerWidth} />
367
+ <box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
368
+ {state.error ? (
369
+ <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} />
370
+ ) : (
371
+ <>
372
+ {Array.from({ length: topRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
373
+ <PlainLine text={titleLines[0]!} fg={colors.muted} />
374
+ <PlainLine text={titleLines[1]!} fg={colors.text} bold />
375
+ {Array.from({ length: bottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
376
+ </>
377
+ )}
378
+ </box>
379
+ <Divider width={innerWidth} />
380
+ <box height={1} paddingLeft={1} paddingRight={1}>
381
+ <TextLine>
382
+ <span fg={colors.count}>enter</span>
383
+ <span fg={colors.muted}> close </span>
384
+ <span fg={colors.count}>esc</span>
385
+ <span fg={colors.muted}> cancel</span>
386
+ </TextLine>
387
+ </box>
388
+ </ModalFrame>
389
+ )
390
+ }
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
+
265
579
  export const ThemeModal = ({
266
580
  state,
267
581
  activeThemeId,
@@ -18,6 +18,8 @@ export const checkLabel = (pullRequest: PullRequestItem) => pullRequest.checkSum
18
18
  export const statusColor = (status: PullRequestItem["reviewStatus"] | PullRequestItem["checkStatus"]) => colors.status[status]
19
19
 
20
20
  export const reviewIcon = (pullRequest: PullRequestItem) => {
21
+ if (pullRequest.state === "merged") return "✓"
22
+ if (pullRequest.state === "closed") return "×"
21
23
  if (pullRequest.autoMergeEnabled) return "↻"
22
24
  if (pullRequest.reviewStatus === "draft") return "◌"
23
25
  if (pullRequest.reviewStatus === "approved") return "✓"