@kitlangton/ghui 0.1.19 → 0.1.21
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 +14 -3
- package/bin/ghui.js +64 -1
- package/package.json +18 -7
- package/src/App.tsx +1075 -653
- package/src/appCommands.ts +330 -0
- package/src/commands.ts +73 -0
- package/src/config.ts +10 -0
- package/src/domain.ts +29 -20
- package/src/errors.ts +10 -0
- package/src/index.tsx +30 -3
- package/src/mergeActions.ts +1 -6
- package/src/pullRequestCache.ts +19 -0
- package/src/pullRequestViews.ts +45 -0
- package/src/services/BrowserOpener.ts +22 -0
- package/src/services/Clipboard.ts +46 -0
- package/src/services/CommandRunner.ts +14 -6
- package/src/services/GitHubService.ts +290 -126
- package/src/services/MockGitHubService.ts +146 -0
- package/src/ui/CommandPalette.tsx +156 -0
- package/src/ui/DetailsPane.tsx +49 -63
- package/src/ui/FooterHints.tsx +84 -179
- package/src/ui/PullRequestDiffPane.tsx +29 -50
- package/src/ui/PullRequestList.tsx +99 -45
- package/src/ui/colors.ts +167 -1
- package/src/ui/diff.ts +34 -44
- package/src/ui/diffStats.tsx +25 -0
- package/src/ui/modals.tsx +263 -295
- package/src/ui/primitives.tsx +90 -0
- package/src/ui/pullRequests.ts +44 -12
- package/src/ui/singleLineInput.ts +25 -0
package/src/ui/modals.tsx
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { TextAttributes } from "@opentui/core"
|
|
2
1
|
import { Data } from "effect"
|
|
3
2
|
import { formatShortDate, formatTimestamp } from "../date.js"
|
|
4
3
|
import type { PullRequestLabel, PullRequestMergeInfo, PullRequestReviewComment } from "../domain.js"
|
|
5
4
|
import { availableMergeActions } from "../mergeActions.js"
|
|
6
5
|
import { clampCursor, commentEditorLines, cursorLineIndexForLines } from "./commentEditor.js"
|
|
7
6
|
import { colors, filterThemeDefinitions, themeDefinitions, type ThemeId } from "./colors.js"
|
|
8
|
-
import { centerCell,
|
|
7
|
+
import { centerCell, Filler, fitCell, HintRow, PlainLine, StandardModal, standardModalDims, TextLine } from "./primitives.js"
|
|
9
8
|
import { labelColor, shortRepoName } from "./pullRequests.js"
|
|
10
9
|
|
|
11
10
|
export interface LabelModalState {
|
|
@@ -51,6 +50,22 @@ export interface ThemeModalState {
|
|
|
51
50
|
readonly initialThemeId: ThemeId
|
|
52
51
|
}
|
|
53
52
|
|
|
53
|
+
export interface CommandPaletteState {
|
|
54
|
+
readonly query: string
|
|
55
|
+
readonly selectedIndex: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface OpenRepositoryModalState {
|
|
59
|
+
readonly query: string
|
|
60
|
+
readonly error: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const filterLabels = (labels: readonly PullRequestLabel[], query: string) => {
|
|
64
|
+
const normalized = query.trim().toLowerCase()
|
|
65
|
+
if (normalized.length === 0) return labels
|
|
66
|
+
return labels.filter((label) => label.name.toLowerCase().includes(normalized))
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
export const initialLabelModalState: LabelModalState = {
|
|
55
70
|
repository: null,
|
|
56
71
|
query: "",
|
|
@@ -94,6 +109,16 @@ export const initialThemeModalState: ThemeModalState = {
|
|
|
94
109
|
initialThemeId: "ghui",
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
export const initialCommandPaletteState: CommandPaletteState = {
|
|
113
|
+
query: "",
|
|
114
|
+
selectedIndex: 0,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const initialOpenRepositoryModalState: OpenRepositoryModalState = {
|
|
118
|
+
query: "",
|
|
119
|
+
error: null,
|
|
120
|
+
}
|
|
121
|
+
|
|
97
122
|
export type Modal = Data.TaggedEnum<{
|
|
98
123
|
None: {}
|
|
99
124
|
Label: LabelModalState
|
|
@@ -102,6 +127,8 @@ export type Modal = Data.TaggedEnum<{
|
|
|
102
127
|
Comment: CommentModalState
|
|
103
128
|
CommentThread: CommentThreadModalState
|
|
104
129
|
Theme: ThemeModalState
|
|
130
|
+
CommandPalette: CommandPaletteState
|
|
131
|
+
OpenRepository: OpenRepositoryModalState
|
|
105
132
|
}>
|
|
106
133
|
|
|
107
134
|
export const Modal = Data.taggedEnum<Modal>()
|
|
@@ -117,8 +144,52 @@ export const modalInitialStates = {
|
|
|
117
144
|
Comment: initialCommentModalState,
|
|
118
145
|
CommentThread: initialCommentThreadModalState,
|
|
119
146
|
Theme: initialThemeModalState,
|
|
147
|
+
CommandPalette: initialCommandPaletteState,
|
|
148
|
+
OpenRepository: initialOpenRepositoryModalState,
|
|
120
149
|
} as const satisfies { [Tag in Exclude<ModalTag, "None">]: ModalState<Tag> }
|
|
121
150
|
|
|
151
|
+
export const OpenRepositoryModal = ({
|
|
152
|
+
state,
|
|
153
|
+
modalWidth,
|
|
154
|
+
modalHeight,
|
|
155
|
+
offsetLeft,
|
|
156
|
+
offsetTop,
|
|
157
|
+
}: {
|
|
158
|
+
state: OpenRepositoryModalState
|
|
159
|
+
modalWidth: number
|
|
160
|
+
modalHeight: number
|
|
161
|
+
offsetLeft: number
|
|
162
|
+
offsetTop: number
|
|
163
|
+
}) => {
|
|
164
|
+
const { contentWidth } = standardModalDims(modalWidth, modalHeight)
|
|
165
|
+
const inputText = state.query.length > 0 ? state.query : "owner/name or GitHub URL"
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<StandardModal
|
|
169
|
+
left={offsetLeft}
|
|
170
|
+
top={offsetTop}
|
|
171
|
+
width={modalWidth}
|
|
172
|
+
height={modalHeight}
|
|
173
|
+
title="Open Repository"
|
|
174
|
+
headerRight={{ text: "owner/name" }}
|
|
175
|
+
subtitle={
|
|
176
|
+
<TextLine>
|
|
177
|
+
<span fg={colors.count}>› </span>
|
|
178
|
+
<span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(inputText, Math.max(1, contentWidth - 2))}</span>
|
|
179
|
+
</TextLine>
|
|
180
|
+
}
|
|
181
|
+
bodyPadding={1}
|
|
182
|
+
footer={<HintRow items={[{ key: "enter", label: "open" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "cancel" }]} />}
|
|
183
|
+
>
|
|
184
|
+
{state.error ? (
|
|
185
|
+
<PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} />
|
|
186
|
+
) : (
|
|
187
|
+
<PlainLine text={fitCell("Switches to the selected repository view.", contentWidth)} fg={colors.muted} />
|
|
188
|
+
)}
|
|
189
|
+
</StandardModal>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
122
193
|
const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
|
|
123
194
|
if (!info) return "Loading merge status from GitHub."
|
|
124
195
|
if (info.state !== "open") return "This pull request is not open."
|
|
@@ -144,14 +215,9 @@ export const LabelModal = ({
|
|
|
144
215
|
offsetTop: number
|
|
145
216
|
loadingIndicator: string
|
|
146
217
|
}) => {
|
|
147
|
-
const
|
|
148
|
-
const contentWidth = Math.max(14, innerWidth - 2)
|
|
149
|
-
const rowWidth = innerWidth
|
|
218
|
+
const { contentWidth, bodyHeight: maxVisible, rowWidth } = standardModalDims(modalWidth, modalHeight)
|
|
150
219
|
const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
|
|
151
|
-
const filtered = state.availableLabels.
|
|
152
|
-
state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
|
|
153
|
-
)
|
|
154
|
-
const maxVisible = Math.max(1, modalHeight - 7)
|
|
220
|
+
const filtered = filterLabels(state.availableLabels, state.query)
|
|
155
221
|
const labelMessageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
|
|
156
222
|
const labelMessageBottomRows = Math.max(0, maxVisible - labelMessageTopRows - 1)
|
|
157
223
|
const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
|
|
@@ -162,62 +228,25 @@ export const LabelModal = ({
|
|
|
162
228
|
const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
|
|
163
229
|
const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
|
|
164
230
|
const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
|
|
165
|
-
const headerGap = Math.max(1, contentWidth - title.length - countText.length)
|
|
166
231
|
const queryText = state.query.length > 0 ? state.query : "type to filter labels"
|
|
167
|
-
const queryPrefix =
|
|
232
|
+
const queryPrefix = "/ "
|
|
168
233
|
const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
|
|
169
234
|
|
|
170
235
|
return (
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
236
|
+
<StandardModal
|
|
237
|
+
left={offsetLeft}
|
|
238
|
+
top={offsetTop}
|
|
239
|
+
width={modalWidth}
|
|
240
|
+
height={modalHeight}
|
|
241
|
+
title={title}
|
|
242
|
+
headerRight={{ text: countText }}
|
|
243
|
+
subtitle={
|
|
180
244
|
<TextLine>
|
|
181
245
|
<span fg={colors.count}>{queryPrefix}</span>
|
|
182
246
|
<span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
|
|
183
247
|
</TextLine>
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<box height={maxVisible} flexDirection="column">
|
|
187
|
-
{state.loading ? (
|
|
188
|
-
<>
|
|
189
|
-
{Array.from({ length: labelMessageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
|
|
190
|
-
<PlainLine text={centerCell(`${loadingIndicator} Loading labels`, rowWidth)} fg={colors.muted} />
|
|
191
|
-
{Array.from({ length: labelMessageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
|
|
192
|
-
</>
|
|
193
|
-
) : visibleLabels.length === 0 ? (
|
|
194
|
-
<>
|
|
195
|
-
{Array.from({ length: labelMessageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
|
|
196
|
-
<PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", rowWidth)} fg={colors.muted} />
|
|
197
|
-
{Array.from({ length: labelMessageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
|
|
198
|
-
</>
|
|
199
|
-
) : (
|
|
200
|
-
visibleLabels.map((label, index) => {
|
|
201
|
-
const actualIndex = scrollStart + index
|
|
202
|
-
const isActive = currentNames.has(label.name.toLowerCase())
|
|
203
|
-
const isSelected = actualIndex === selectedIndex
|
|
204
|
-
const marker = isActive ? "✓" : " "
|
|
205
|
-
const nameWidth = Math.max(1, rowWidth - 5)
|
|
206
|
-
return (
|
|
207
|
-
<box key={label.name} height={1}>
|
|
208
|
-
<TextLine bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
209
|
-
<span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
|
|
210
|
-
<span> </span>
|
|
211
|
-
<span bg={labelColor(label)}> </span>
|
|
212
|
-
<span> {fitCell(label.name, nameWidth)}</span>
|
|
213
|
-
</TextLine>
|
|
214
|
-
</box>
|
|
215
|
-
)
|
|
216
|
-
})
|
|
217
|
-
)}
|
|
218
|
-
</box>
|
|
219
|
-
<Divider width={innerWidth} />
|
|
220
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
248
|
+
}
|
|
249
|
+
footer={
|
|
221
250
|
<TextLine>
|
|
222
251
|
<span fg={colors.count}>↑↓</span>
|
|
223
252
|
<span fg={colors.muted}> move </span>
|
|
@@ -225,8 +254,40 @@ export const LabelModal = ({
|
|
|
225
254
|
<span fg={colors.muted}> close</span>
|
|
226
255
|
{filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
|
|
227
256
|
</TextLine>
|
|
228
|
-
|
|
229
|
-
|
|
257
|
+
}
|
|
258
|
+
>
|
|
259
|
+
{state.loading ? (
|
|
260
|
+
<>
|
|
261
|
+
<Filler rows={labelMessageTopRows} prefix="top" />
|
|
262
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading labels`, rowWidth)} fg={colors.muted} />
|
|
263
|
+
<Filler rows={labelMessageBottomRows} prefix="bottom" />
|
|
264
|
+
</>
|
|
265
|
+
) : visibleLabels.length === 0 ? (
|
|
266
|
+
<>
|
|
267
|
+
<Filler rows={labelMessageTopRows} prefix="top" />
|
|
268
|
+
<PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", rowWidth)} fg={colors.muted} />
|
|
269
|
+
<Filler rows={labelMessageBottomRows} prefix="bottom" />
|
|
270
|
+
</>
|
|
271
|
+
) : (
|
|
272
|
+
visibleLabels.map((label, index) => {
|
|
273
|
+
const actualIndex = scrollStart + index
|
|
274
|
+
const isActive = currentNames.has(label.name.toLowerCase())
|
|
275
|
+
const isSelected = actualIndex === selectedIndex
|
|
276
|
+
const marker = isActive ? "✓" : " "
|
|
277
|
+
const nameWidth = Math.max(1, rowWidth - 5)
|
|
278
|
+
return (
|
|
279
|
+
<box key={label.name} height={1}>
|
|
280
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
281
|
+
<span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
|
|
282
|
+
<span> </span>
|
|
283
|
+
<span bg={labelColor(label)}> </span>
|
|
284
|
+
<span> {fitCell(label.name, nameWidth)}</span>
|
|
285
|
+
</TextLine>
|
|
286
|
+
</box>
|
|
287
|
+
)
|
|
288
|
+
})
|
|
289
|
+
)}
|
|
290
|
+
</StandardModal>
|
|
230
291
|
)
|
|
231
292
|
}
|
|
232
293
|
|
|
@@ -245,80 +306,61 @@ export const MergeModal = ({
|
|
|
245
306
|
offsetTop: number
|
|
246
307
|
loadingIndicator: string
|
|
247
308
|
}) => {
|
|
248
|
-
const
|
|
249
|
-
const contentWidth = Math.max(14, innerWidth - 2)
|
|
250
|
-
const rowWidth = innerWidth
|
|
309
|
+
const { contentWidth, bodyHeight: optionAreaHeight, rowWidth } = standardModalDims(modalWidth, modalHeight)
|
|
251
310
|
const options = availableMergeActions(state.info)
|
|
252
311
|
const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
|
|
253
312
|
const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
|
|
254
313
|
const rightText = state.running ? `${loadingIndicator} running` : state.loading ? `${loadingIndicator} loading` : state.info?.autoMergeEnabled ? "auto on" : "manual"
|
|
255
|
-
const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
|
|
256
314
|
const repo = state.info?.repository ?? state.repository
|
|
257
315
|
const statusLine = state.info
|
|
258
316
|
? `${shortRepoName(state.info.repository)} ${state.info.mergeable} ${state.info.reviewStatus} ${state.info.checkSummary ?? state.info.checkStatus}`
|
|
259
317
|
: repo ? shortRepoName(repo) : ""
|
|
260
|
-
const optionAreaHeight = Math.max(1, modalHeight - 7)
|
|
261
318
|
const optionRows = Math.max(1, Math.floor(optionAreaHeight / 2))
|
|
262
319
|
const visibleOptions = options.slice(0, optionRows)
|
|
263
320
|
const loadingTopRows = Math.max(0, Math.floor((optionAreaHeight - 1) / 2))
|
|
264
321
|
const loadingBottomRows = Math.max(0, optionAreaHeight - loadingTopRows - 1)
|
|
265
322
|
|
|
266
323
|
return (
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
})
|
|
308
|
-
)}
|
|
309
|
-
</box>
|
|
310
|
-
<Divider width={innerWidth} />
|
|
311
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
312
|
-
<TextLine>
|
|
313
|
-
<span fg={colors.count}>↑↓</span>
|
|
314
|
-
<span fg={colors.muted}> move </span>
|
|
315
|
-
<span fg={colors.count}>enter</span>
|
|
316
|
-
<span fg={colors.muted}> confirm </span>
|
|
317
|
-
<span fg={colors.count}>esc</span>
|
|
318
|
-
<span fg={colors.muted}> close</span>
|
|
319
|
-
</TextLine>
|
|
320
|
-
</box>
|
|
321
|
-
</ModalFrame>
|
|
324
|
+
<StandardModal
|
|
325
|
+
left={offsetLeft}
|
|
326
|
+
top={offsetTop}
|
|
327
|
+
width={modalWidth}
|
|
328
|
+
height={modalHeight}
|
|
329
|
+
title={title}
|
|
330
|
+
headerRight={{ text: rightText, pending: state.running || state.loading }}
|
|
331
|
+
subtitle={<PlainLine text={fitCell(statusLine, contentWidth)} fg={colors.muted} />}
|
|
332
|
+
footer={<HintRow items={[{ key: "↑↓", label: "move" }, { key: "enter", label: "confirm" }, { key: "esc", label: "close" }]} />}
|
|
333
|
+
>
|
|
334
|
+
{state.loading ? (
|
|
335
|
+
<>
|
|
336
|
+
<Filler rows={loadingTopRows} prefix="top" />
|
|
337
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading merge status`, rowWidth)} fg={colors.muted} />
|
|
338
|
+
<Filler rows={loadingBottomRows} prefix="bottom" />
|
|
339
|
+
</>
|
|
340
|
+
) : state.error ? (
|
|
341
|
+
<PlainLine text={centerCell(state.error, rowWidth)} fg={colors.error} />
|
|
342
|
+
) : visibleOptions.length === 0 ? (
|
|
343
|
+
<PlainLine text={centerCell(mergeUnavailableReason(state.info), rowWidth)} fg={colors.muted} />
|
|
344
|
+
) : (
|
|
345
|
+
visibleOptions.map((option, index) => {
|
|
346
|
+
const isSelected = index === selectedIndex
|
|
347
|
+
const titleColor = option.danger ? colors.error : isSelected ? colors.selectedText : colors.text
|
|
348
|
+
const titleWidth = Math.max(1, rowWidth - 1)
|
|
349
|
+
const descriptionWidth = Math.max(1, rowWidth - 1)
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<box key={option.action} height={2} flexDirection="column">
|
|
353
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
354
|
+
<span fg={titleColor}> {fitCell(option.title, titleWidth)}</span>
|
|
355
|
+
</TextLine>
|
|
356
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
357
|
+
<span fg={colors.muted}> {fitCell(option.description, descriptionWidth)}</span>
|
|
358
|
+
</TextLine>
|
|
359
|
+
</box>
|
|
360
|
+
)
|
|
361
|
+
})
|
|
362
|
+
)}
|
|
363
|
+
</StandardModal>
|
|
322
364
|
)
|
|
323
365
|
}
|
|
324
366
|
|
|
@@ -337,55 +379,38 @@ export const CloseModal = ({
|
|
|
337
379
|
offsetTop: number
|
|
338
380
|
loadingIndicator: string
|
|
339
381
|
}) => {
|
|
340
|
-
const
|
|
341
|
-
const contentWidth = Math.max(14, innerWidth - 2)
|
|
382
|
+
const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
|
|
342
383
|
const title = state.number ? `Close #${state.number}` : "Close pull request"
|
|
343
384
|
const rightText = state.running ? `${loadingIndicator} closing` : "confirm"
|
|
344
|
-
const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
|
|
345
385
|
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)
|
|
386
|
+
const titleLines = [fitCell(repo, contentWidth), fitCell(state.title, contentWidth)]
|
|
351
387
|
const topRows = Math.max(0, Math.floor((bodyHeight - titleLines.length - 2) / 2))
|
|
352
388
|
const bottomRows = Math.max(0, bodyHeight - topRows - titleLines.length - 2)
|
|
353
389
|
|
|
354
390
|
return (
|
|
355
|
-
<
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
<
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
{state.error
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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>
|
|
391
|
+
<StandardModal
|
|
392
|
+
left={offsetLeft}
|
|
393
|
+
top={offsetTop}
|
|
394
|
+
width={modalWidth}
|
|
395
|
+
height={modalHeight}
|
|
396
|
+
title={title}
|
|
397
|
+
titleFg={colors.error}
|
|
398
|
+
headerRight={{ text: rightText, pending: state.running }}
|
|
399
|
+
subtitle={<PlainLine text={fitCell("This will close the pull request without merging it.", contentWidth)} fg={colors.muted} />}
|
|
400
|
+
bodyPadding={1}
|
|
401
|
+
footer={<HintRow items={[{ key: "enter", label: "close" }, { key: "esc", label: "cancel" }]} />}
|
|
402
|
+
>
|
|
403
|
+
{state.error ? (
|
|
404
|
+
<PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} />
|
|
405
|
+
) : (
|
|
406
|
+
<>
|
|
407
|
+
<Filler rows={topRows} prefix="top" />
|
|
408
|
+
<PlainLine text={titleLines[0]!} fg={colors.muted} />
|
|
409
|
+
<PlainLine text={titleLines[1]!} fg={colors.text} bold />
|
|
410
|
+
<Filler rows={bottomRows} prefix="bottom" />
|
|
411
|
+
</>
|
|
412
|
+
)}
|
|
413
|
+
</StandardModal>
|
|
389
414
|
)
|
|
390
415
|
}
|
|
391
416
|
|
|
@@ -404,12 +429,8 @@ export const CommentModal = ({
|
|
|
404
429
|
offsetLeft: number
|
|
405
430
|
offsetTop: number
|
|
406
431
|
}) => {
|
|
407
|
-
const
|
|
408
|
-
const contentWidth = Math.max(14, innerWidth - 2)
|
|
432
|
+
const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
|
|
409
433
|
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
434
|
const editorHeight = Math.max(1, bodyHeight - (state.error ? 1 : 0))
|
|
414
435
|
const lineRanges = commentEditorLines(state.body)
|
|
415
436
|
const cursor = clampCursor(state.body, state.cursor)
|
|
@@ -446,34 +467,20 @@ export const CommentModal = ({
|
|
|
446
467
|
}
|
|
447
468
|
|
|
448
469
|
return (
|
|
449
|
-
<
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
<
|
|
461
|
-
|
|
462
|
-
|
|
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>
|
|
470
|
+
<StandardModal
|
|
471
|
+
left={offsetLeft}
|
|
472
|
+
top={offsetTop}
|
|
473
|
+
width={modalWidth}
|
|
474
|
+
height={modalHeight}
|
|
475
|
+
title={title}
|
|
476
|
+
headerRight={{ text: "enter save" }}
|
|
477
|
+
subtitle={<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />}
|
|
478
|
+
bodyPadding={1}
|
|
479
|
+
footer={<HintRow items={[{ key: "enter", label: "save" }, { key: "shift-enter", label: "newline" }, { key: "esc", label: "cancel" }]} />}
|
|
480
|
+
>
|
|
481
|
+
{state.error ? <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} /> : null}
|
|
482
|
+
{visibleLines.map(renderEditorLine)}
|
|
483
|
+
</StandardModal>
|
|
477
484
|
)
|
|
478
485
|
}
|
|
479
486
|
|
|
@@ -530,49 +537,32 @@ export const CommentThreadModal = ({
|
|
|
530
537
|
offsetLeft: number
|
|
531
538
|
offsetTop: number
|
|
532
539
|
}) => {
|
|
533
|
-
const
|
|
534
|
-
const contentWidth = Math.max(14, innerWidth - 2)
|
|
540
|
+
const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
|
|
535
541
|
const title = "Thread"
|
|
536
542
|
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
543
|
const rows = commentThreadRows(comments, contentWidth)
|
|
540
544
|
const maxScroll = Math.max(0, rows.length - bodyHeight)
|
|
541
545
|
const scrollOffset = Math.max(0, Math.min(state.scrollOffset, maxScroll))
|
|
542
546
|
const visibleRows = rows.slice(scrollOffset, scrollOffset + bodyHeight)
|
|
543
547
|
|
|
544
548
|
return (
|
|
545
|
-
<
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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>
|
|
549
|
+
<StandardModal
|
|
550
|
+
left={offsetLeft}
|
|
551
|
+
top={offsetTop}
|
|
552
|
+
width={modalWidth}
|
|
553
|
+
height={modalHeight}
|
|
554
|
+
title={title}
|
|
555
|
+
headerRight={{ text: countText }}
|
|
556
|
+
subtitle={<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />}
|
|
557
|
+
bodyPadding={1}
|
|
558
|
+
footer={<HintRow items={[{ key: "↑↓", label: "scroll" }, { key: "a", label: "comment" }, { key: "esc", label: "close" }]} />}
|
|
559
|
+
>
|
|
560
|
+
{visibleRows.length === 0 ? (
|
|
561
|
+
<PlainLine text={fitCell("No comments on this line.", contentWidth)} fg={colors.muted} />
|
|
562
|
+
) : visibleRows.map((row) => (
|
|
563
|
+
<PlainLine key={row.key} text={fitCell(row.text, contentWidth)} fg={row.fg} bold={row.bold ?? false} />
|
|
564
|
+
))}
|
|
565
|
+
</StandardModal>
|
|
576
566
|
)
|
|
577
567
|
}
|
|
578
568
|
|
|
@@ -591,11 +581,8 @@ export const ThemeModal = ({
|
|
|
591
581
|
offsetLeft: number
|
|
592
582
|
offsetTop: number
|
|
593
583
|
}) => {
|
|
594
|
-
const
|
|
595
|
-
const contentWidth = Math.max(14, innerWidth - 2)
|
|
596
|
-
const rowWidth = innerWidth
|
|
584
|
+
const { contentWidth, bodyHeight: maxVisible, rowWidth } = standardModalDims(modalWidth, modalHeight)
|
|
597
585
|
const filteredThemes = filterThemeDefinitions(state.query)
|
|
598
|
-
const maxVisible = Math.max(1, modalHeight - 7)
|
|
599
586
|
const activeIndex = filteredThemes.findIndex((theme) => theme.id === activeThemeId)
|
|
600
587
|
const selectedIndex = Math.max(0, activeIndex)
|
|
601
588
|
const selectedTheme = filteredThemes[selectedIndex] ?? themeDefinitions.find((theme) => theme.id === activeThemeId) ?? themeDefinitions[0]!
|
|
@@ -605,8 +592,6 @@ export const ThemeModal = ({
|
|
|
605
592
|
)
|
|
606
593
|
const visibleThemes = filteredThemes.slice(scrollStart, scrollStart + maxVisible)
|
|
607
594
|
const countText = `${filteredThemes.length === 0 ? 0 : selectedIndex + 1}/${filteredThemes.length}`
|
|
608
|
-
const title = "Themes"
|
|
609
|
-
const headerGap = Math.max(1, contentWidth - title.length - countText.length)
|
|
610
595
|
const subtitleText = state.filterMode ? (state.query.length > 0 ? state.query : "type to filter themes") : selectedTheme.description
|
|
611
596
|
const queryPrefix = "/ "
|
|
612
597
|
const subtitleWidth = Math.max(1, contentWidth - (state.filterMode ? queryPrefix.length : 0))
|
|
@@ -614,68 +599,51 @@ export const ThemeModal = ({
|
|
|
614
599
|
const messageBottomRows = Math.max(0, maxVisible - messageTopRows - 1)
|
|
615
600
|
|
|
616
601
|
return (
|
|
617
|
-
<
|
|
618
|
-
|
|
602
|
+
<StandardModal
|
|
603
|
+
left={offsetLeft}
|
|
604
|
+
top={offsetTop}
|
|
605
|
+
width={modalWidth}
|
|
606
|
+
height={modalHeight}
|
|
607
|
+
title="Themes"
|
|
608
|
+
headerRight={{ text: countText }}
|
|
609
|
+
subtitle={state.filterMode ? (
|
|
619
610
|
<TextLine>
|
|
620
|
-
<span fg={colors.
|
|
621
|
-
<span fg={colors.muted}>{
|
|
622
|
-
<span fg={colors.muted}>{countText}</span>
|
|
611
|
+
<span fg={colors.count}>{queryPrefix}</span>
|
|
612
|
+
<span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(subtitleText, subtitleWidth)}</span>
|
|
623
613
|
</TextLine>
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
614
|
+
) : (
|
|
615
|
+
<PlainLine text={fitCell(subtitleText, subtitleWidth)} fg={colors.muted} />
|
|
616
|
+
)}
|
|
617
|
+
footer={<HintRow items={[{ key: "↑↓", label: "preview" }, { key: "/", label: "filter" }, { key: "enter", label: "select" }, { key: "esc", label: "cancel" }]} />}
|
|
618
|
+
>
|
|
619
|
+
{visibleThemes.length === 0 ? (
|
|
620
|
+
<>
|
|
621
|
+
<Filler rows={messageTopRows} prefix="top" />
|
|
622
|
+
<PlainLine text={centerCell("No matching themes", rowWidth)} fg={colors.muted} />
|
|
623
|
+
<Filler rows={messageBottomRows} prefix="bottom" />
|
|
624
|
+
</>
|
|
625
|
+
) : visibleThemes.map((theme, index) => {
|
|
626
|
+
const actualIndex = scrollStart + index
|
|
627
|
+
const isSelected = actualIndex === selectedIndex
|
|
628
|
+
const isActive = theme.id === activeThemeId
|
|
629
|
+
const marker = isActive ? "✓" : " "
|
|
630
|
+
const swatchWidth = 6
|
|
631
|
+
const nameWidth = Math.max(1, rowWidth - swatchWidth - 3)
|
|
632
|
+
|
|
633
|
+
return (
|
|
634
|
+
<TextLine key={theme.id} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
635
|
+
<span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
|
|
636
|
+
<span> </span>
|
|
637
|
+
<span>{fitCell(theme.name, nameWidth)}</span>
|
|
638
|
+
<span bg={theme.colors.background}> </span>
|
|
639
|
+
<span bg={theme.colors.modalBackground}> </span>
|
|
640
|
+
<span bg={theme.colors.accent}> </span>
|
|
641
|
+
<span bg={theme.colors.status.passing}> </span>
|
|
642
|
+
<span bg={theme.colors.status.failing}> </span>
|
|
643
|
+
<span bg={theme.colors.status.review}> </span>
|
|
630
644
|
</TextLine>
|
|
631
|
-
)
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
</box>
|
|
635
|
-
<Divider width={innerWidth} />
|
|
636
|
-
<box height={maxVisible} flexDirection="column">
|
|
637
|
-
{visibleThemes.length === 0 ? (
|
|
638
|
-
<>
|
|
639
|
-
{Array.from({ length: messageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
|
|
640
|
-
<PlainLine text={centerCell("No matching themes", rowWidth)} fg={colors.muted} />
|
|
641
|
-
{Array.from({ length: messageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
|
|
642
|
-
</>
|
|
643
|
-
) : visibleThemes.map((theme, index) => {
|
|
644
|
-
const actualIndex = scrollStart + index
|
|
645
|
-
const isSelected = actualIndex === selectedIndex
|
|
646
|
-
const isActive = theme.id === activeThemeId
|
|
647
|
-
const marker = isActive ? "✓" : " "
|
|
648
|
-
const swatchWidth = 6
|
|
649
|
-
const nameWidth = Math.max(1, rowWidth - swatchWidth - 3)
|
|
650
|
-
|
|
651
|
-
return (
|
|
652
|
-
<TextLine key={theme.id} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
653
|
-
<span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
|
|
654
|
-
<span> </span>
|
|
655
|
-
<span>{fitCell(theme.name, nameWidth)}</span>
|
|
656
|
-
<span bg={theme.colors.background}> </span>
|
|
657
|
-
<span bg={theme.colors.modalBackground}> </span>
|
|
658
|
-
<span bg={theme.colors.accent}> </span>
|
|
659
|
-
<span bg={theme.colors.status.passing}> </span>
|
|
660
|
-
<span bg={theme.colors.status.failing}> </span>
|
|
661
|
-
<span bg={theme.colors.status.review}> </span>
|
|
662
|
-
</TextLine>
|
|
663
|
-
)
|
|
664
|
-
})}
|
|
665
|
-
</box>
|
|
666
|
-
<Divider width={innerWidth} />
|
|
667
|
-
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
668
|
-
<TextLine>
|
|
669
|
-
<span fg={colors.count}>↑↓</span>
|
|
670
|
-
<span fg={colors.muted}> preview </span>
|
|
671
|
-
<span fg={colors.count}>/</span>
|
|
672
|
-
<span fg={colors.muted}> filter </span>
|
|
673
|
-
<span fg={colors.count}>enter</span>
|
|
674
|
-
<span fg={colors.muted}> select </span>
|
|
675
|
-
<span fg={colors.count}>esc</span>
|
|
676
|
-
<span fg={colors.muted}> cancel</span>
|
|
677
|
-
</TextLine>
|
|
678
|
-
</box>
|
|
679
|
-
</ModalFrame>
|
|
645
|
+
)
|
|
646
|
+
})}
|
|
647
|
+
</StandardModal>
|
|
680
648
|
)
|
|
681
649
|
}
|