@kitlangton/ghui 0.1.18 → 0.1.20

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.
@@ -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}
@@ -51,6 +51,13 @@ export const SectionTitle = ({ title }: { title: string }) => (
51
51
  </TextLine>
52
52
  )
53
53
 
54
+ export const Filler = ({ rows, prefix }: { rows: number; prefix: string }) =>
55
+ <>{Array.from({ length: rows }, (_, index) => <box key={`${prefix}-${index}`} height={1} />)}</>
56
+
57
+ export const PaddedRow = ({ children, backgroundColor }: { children: React.ReactNode; backgroundColor?: string }) => (
58
+ <box height={1} paddingLeft={1} paddingRight={1} {...(backgroundColor ? { backgroundColor } : {})}>{children}</box>
59
+ )
60
+
54
61
  export const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
55
62
  if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
56
63
  return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
@@ -70,6 +77,89 @@ export const SeparatorColumn = ({ height, junctionRows }: { height: number; junc
70
77
  )
71
78
  }
72
79
 
80
+ export type StandardModalDims = {
81
+ readonly innerWidth: number
82
+ readonly contentWidth: number
83
+ readonly bodyHeight: number
84
+ readonly rowWidth: number
85
+ }
86
+
87
+ export const standardModalDims = (modalWidth: number, modalHeight: number): StandardModalDims => {
88
+ const innerWidth = Math.max(16, modalWidth - 2)
89
+ const contentWidth = Math.max(14, innerWidth - 2)
90
+ const bodyHeight = Math.max(1, modalHeight - 7)
91
+ return { innerWidth, contentWidth, bodyHeight, rowWidth: innerWidth }
92
+ }
93
+
94
+ export type HintItem = {
95
+ readonly key: string
96
+ readonly label: string
97
+ readonly when?: boolean
98
+ readonly keyFg?: string
99
+ }
100
+
101
+ export const HintRow = ({ items }: { items: readonly HintItem[] }) => {
102
+ const visible = items.filter((item) => item.when !== false)
103
+ return (
104
+ <TextLine>
105
+ {visible.flatMap((item, index) => [
106
+ <span key={`k${index}`} fg={item.keyFg ?? colors.count}>{item.key}</span>,
107
+ <span key={`l${index}`} fg={colors.muted}>{` ${item.label}${index < visible.length - 1 ? " " : ""}`}</span>,
108
+ ])}
109
+ </TextLine>
110
+ )
111
+ }
112
+
113
+ export const StandardModal = ({
114
+ left,
115
+ top,
116
+ width,
117
+ height,
118
+ title,
119
+ titleFg = colors.accent,
120
+ headerRight,
121
+ subtitle,
122
+ footer,
123
+ bodyPadding = 0,
124
+ children,
125
+ }: {
126
+ left: number
127
+ top: number
128
+ width: number
129
+ height: number
130
+ title: string
131
+ titleFg?: string
132
+ headerRight?: { readonly text: string; readonly pending?: boolean }
133
+ subtitle: React.ReactNode
134
+ footer: React.ReactNode
135
+ bodyPadding?: number
136
+ children: React.ReactNode
137
+ }) => {
138
+ const { innerWidth, contentWidth, bodyHeight } = standardModalDims(width, height)
139
+ const rightText = headerRight?.text ?? ""
140
+ const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
141
+ return (
142
+ <ModalFrame left={left} top={top} width={width} height={height} junctionRows={[2, height - 4]}>
143
+ <PaddedRow>
144
+ <TextLine>
145
+ <span fg={titleFg} attributes={TextAttributes.BOLD}>{title}</span>
146
+ {headerRight ? (
147
+ <>
148
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
149
+ <span fg={headerRight.pending ? colors.status.pending : colors.muted}>{headerRight.text}</span>
150
+ </>
151
+ ) : null}
152
+ </TextLine>
153
+ </PaddedRow>
154
+ <PaddedRow>{subtitle}</PaddedRow>
155
+ <Divider width={innerWidth} />
156
+ <box height={bodyHeight} flexDirection="column" paddingLeft={bodyPadding} paddingRight={bodyPadding}>{children}</box>
157
+ <Divider width={innerWidth} />
158
+ <PaddedRow>{footer}</PaddedRow>
159
+ </ModalFrame>
160
+ )
161
+ }
162
+
73
163
  export const ModalFrame = ({
74
164
  children,
75
165
  left,
@@ -1,31 +1,63 @@
1
- import type { PullRequestItem, PullRequestLabel } from "../domain.js"
1
+ import type { PullRequestItem, PullRequestLabel, ReviewStatus } from "../domain.js"
2
2
  import { colors } from "./colors.js"
3
3
 
4
4
  export const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
5
5
 
6
6
  export const repoColor = (repository: string) => colors.repos[shortRepoName(repository) as keyof typeof colors.repos] ?? colors.repos.default
7
7
 
8
- export const reviewLabel = (pullRequest: PullRequestItem) => {
9
- if (pullRequest.reviewStatus === "draft") return "draft"
10
- if (pullRequest.reviewStatus === "approved") return "approved"
11
- if (pullRequest.reviewStatus === "changes") return "changes"
12
- if (pullRequest.reviewStatus === "review") return "review"
13
- return null
8
+ const REVIEW_LABEL: Partial<Record<ReviewStatus, string>> = {
9
+ draft: "draft",
10
+ approved: "approved",
11
+ changes: "changes",
12
+ review: "review",
14
13
  }
15
14
 
15
+ export const reviewLabel = (pullRequest: PullRequestItem) => REVIEW_LABEL[pullRequest.reviewStatus] ?? null
16
+
16
17
  export const checkLabel = (pullRequest: PullRequestItem) => pullRequest.checkSummary
17
18
 
18
19
  export const statusColor = (status: PullRequestItem["reviewStatus"] | PullRequestItem["checkStatus"]) => colors.status[status]
19
20
 
21
+ const REVIEW_ICON: Record<ReviewStatus, string> = {
22
+ draft: "◌",
23
+ approved: "✓",
24
+ changes: "!",
25
+ review: "◐",
26
+ none: "·",
27
+ }
28
+
20
29
  export const reviewIcon = (pullRequest: PullRequestItem) => {
21
30
  if (pullRequest.state === "merged") return "✓"
22
31
  if (pullRequest.state === "closed") return "×"
23
32
  if (pullRequest.autoMergeEnabled) return "↻"
24
- if (pullRequest.reviewStatus === "draft") return "◌"
25
- if (pullRequest.reviewStatus === "approved") return "✓"
26
- if (pullRequest.reviewStatus === "changes") return "!"
27
- if (pullRequest.reviewStatus === "review") return "◐"
28
- return "·"
33
+ return REVIEW_ICON[pullRequest.reviewStatus]
34
+ }
35
+
36
+ export interface PullRequestRowDisplay {
37
+ readonly indicatorFg: string
38
+ readonly rowFg: string
39
+ readonly numberFg: string
40
+ readonly checkFg: string
41
+ readonly checkText: string
42
+ }
43
+
44
+ export const pullRequestRowDisplay = (pullRequest: PullRequestItem, selected: boolean): PullRequestRowDisplay => {
45
+ const isMerged = pullRequest.state === "merged"
46
+ const isClosed = pullRequest.state === "closed"
47
+ const isFinal = isMerged || isClosed
48
+ const indicatorFg = isMerged ? colors.status.passing
49
+ : isClosed ? colors.muted
50
+ : pullRequest.autoMergeEnabled ? colors.accent
51
+ : statusColor(pullRequest.reviewStatus)
52
+ const checkFg = isMerged ? colors.status.passing : isClosed ? colors.muted : statusColor(pullRequest.checkStatus)
53
+ const checkText = isMerged ? "merged" : isClosed ? "closed" : pullRequest.checkSummary?.replace(/^checks\s+/, "") ?? ""
54
+ return {
55
+ indicatorFg,
56
+ rowFg: selected ? colors.selectedText : isFinal ? colors.muted : colors.text,
57
+ numberFg: selected ? colors.accent : isFinal ? colors.muted : colors.count,
58
+ checkFg,
59
+ checkText,
60
+ }
29
61
  }
30
62
 
31
63
  const fallbackLabelColor = (name: string) => {
@@ -0,0 +1,25 @@
1
+ export interface SingleLineInputKey {
2
+ readonly name: string
3
+ readonly sequence: string
4
+ readonly ctrl?: boolean
5
+ readonly meta?: boolean
6
+ }
7
+
8
+ export const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
9
+
10
+ export const singleLineText = (text: string) => text.replace(/[\r\n]+/g, " ")
11
+
12
+ export const printableKeyText = (key: Pick<SingleLineInputKey, "ctrl" | "meta" | "sequence">) => {
13
+ if (key.ctrl || key.meta || key.sequence.length === 0) return null
14
+ return /^[^\x00-\x1f\x7f]+$/.test(key.sequence) ? key.sequence : null
15
+ }
16
+
17
+ export const editSingleLineInput = (value: string, key: SingleLineInputKey) => {
18
+ if (key.ctrl && key.name === "u") return ""
19
+ if (key.ctrl && key.name === "w") return deleteLastWord(value)
20
+ if (key.name === "backspace") return value.slice(0, -1)
21
+ const text = printableKeyText(key)
22
+ return text ? value + text : null
23
+ }
24
+
25
+ export const isSingleLineInputKey = (key: SingleLineInputKey) => editSingleLineInput("", key) !== null