@kitlangton/ghui 0.3.0 → 0.3.3

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.
Files changed (55) hide show
  1. package/README.md +13 -3
  2. package/bin/ghui.js +6 -1
  3. package/dist/index.js +9456 -0
  4. package/package.json +7 -4
  5. package/src/App.tsx +0 -2779
  6. package/src/appCommands.ts +0 -409
  7. package/src/commands.ts +0 -73
  8. package/src/config.ts +0 -20
  9. package/src/date.ts +0 -20
  10. package/src/domain.ts +0 -149
  11. package/src/errors.ts +0 -10
  12. package/src/index.tsx +0 -50
  13. package/src/keyboard/opentuiAdapter.ts +0 -43
  14. package/src/keymap/all.ts +0 -116
  15. package/src/keymap/changedFilesModal.ts +0 -23
  16. package/src/keymap/closeModal.ts +0 -13
  17. package/src/keymap/commandPalette.ts +0 -16
  18. package/src/keymap/commentModal.ts +0 -73
  19. package/src/keymap/commentThreadModal.ts +0 -24
  20. package/src/keymap/detailView.ts +0 -30
  21. package/src/keymap/diffView.ts +0 -93
  22. package/src/keymap/filterMode.ts +0 -13
  23. package/src/keymap/helpers.ts +0 -24
  24. package/src/keymap/labelModal.ts +0 -16
  25. package/src/keymap/listNav.ts +0 -119
  26. package/src/keymap/mergeModal.ts +0 -23
  27. package/src/keymap/openRepositoryModal.ts +0 -13
  28. package/src/keymap/submitReviewModal.ts +0 -48
  29. package/src/keymap/themeModal.ts +0 -49
  30. package/src/mergeActions.ts +0 -88
  31. package/src/observability.ts +0 -46
  32. package/src/pullRequestCache.ts +0 -19
  33. package/src/pullRequestViews.ts +0 -43
  34. package/src/services/BrowserOpener.ts +0 -22
  35. package/src/services/Clipboard.ts +0 -46
  36. package/src/services/CommandRunner.ts +0 -91
  37. package/src/services/GitHubService.ts +0 -756
  38. package/src/services/MockGitHubService.ts +0 -182
  39. package/src/themeStore.ts +0 -60
  40. package/src/ui/CommandPalette.tsx +0 -176
  41. package/src/ui/DetailsPane.tsx +0 -656
  42. package/src/ui/FooterHints.tsx +0 -90
  43. package/src/ui/LoadingLogo.tsx +0 -75
  44. package/src/ui/PullRequestDiffPane.tsx +0 -249
  45. package/src/ui/PullRequestList.tsx +0 -194
  46. package/src/ui/colors.ts +0 -821
  47. package/src/ui/commentEditor.ts +0 -126
  48. package/src/ui/comments.tsx +0 -143
  49. package/src/ui/diff.ts +0 -650
  50. package/src/ui/diffStats.tsx +0 -25
  51. package/src/ui/modals.tsx +0 -964
  52. package/src/ui/primitives.tsx +0 -350
  53. package/src/ui/pullRequests.ts +0 -106
  54. package/src/ui/singleLineInput.ts +0 -26
  55. package/src/ui/spinner.ts +0 -1
package/src/ui/modals.tsx DELETED
@@ -1,964 +0,0 @@
1
- import { Data } from "effect"
2
- import type { PullRequestLabel, PullRequestMergeInfo, PullRequestReviewComment, PullRequestReviewEvent } from "../domain.js"
3
- import { availableMergeActions } from "../mergeActions.js"
4
- import { clampCursor, commentEditorLines, cursorLineIndexForLines } from "./commentEditor.js"
5
- import { colors, filterThemeDefinitions, themeDefinitions, type ThemeId } from "./colors.js"
6
- import { commentDisplayRows, CommentSegmentsLine, type CommentDisplayLine } from "./comments.js"
7
- import { diffFileStats, diffFileStatsText, type DiffFilePatch } from "./diff.js"
8
- import { centerCell, Filler, fitCell, HintRow, MatchedCell, PlainLine, searchModalDims, SearchModalFrame, StandardModal, standardModalDims, TextLine } from "./primitives.js"
9
- import { labelColor, shortRepoName } from "./pullRequests.js"
10
-
11
- export interface LabelModalState {
12
- readonly repository: string | null
13
- readonly query: string
14
- readonly selectedIndex: number
15
- readonly availableLabels: readonly PullRequestLabel[]
16
- readonly loading: boolean
17
- }
18
-
19
- export interface MergeModalState {
20
- readonly repository: string | null
21
- readonly number: number | null
22
- readonly selectedIndex: number
23
- readonly loading: boolean
24
- readonly running: boolean
25
- readonly info: PullRequestMergeInfo | null
26
- readonly error: string | null
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
-
48
- export interface ChangedFilesModalState {
49
- readonly query: string
50
- readonly selectedIndex: number
51
- }
52
-
53
- export interface SubmitReviewModalState {
54
- readonly repository: string | null
55
- readonly number: number | null
56
- readonly selectedIndex: number
57
- readonly body: string
58
- readonly cursor: number
59
- readonly running: boolean
60
- readonly error: string | null
61
- }
62
-
63
- export interface ThemeModalState {
64
- readonly query: string
65
- readonly filterMode: boolean
66
- readonly initialThemeId: ThemeId
67
- }
68
-
69
- export interface CommandPaletteState {
70
- readonly query: string
71
- readonly selectedIndex: number
72
- }
73
-
74
- export interface OpenRepositoryModalState {
75
- readonly query: string
76
- readonly error: string | null
77
- }
78
-
79
- export const filterLabels = (labels: readonly PullRequestLabel[], query: string) => {
80
- const normalized = query.trim().toLowerCase()
81
- if (normalized.length === 0) return labels
82
- return labels.filter((label) => label.name.toLowerCase().includes(normalized))
83
- }
84
-
85
- export interface ChangedFileSearchResult {
86
- readonly file: DiffFilePatch
87
- readonly index: number
88
- readonly matchIndexes: readonly number[]
89
- }
90
-
91
- interface PathSegment {
92
- readonly text: string
93
- readonly lower: string
94
- readonly start: number
95
- readonly index: number
96
- readonly isBasename: boolean
97
- }
98
-
99
- interface FileTokenMatch {
100
- readonly score: number
101
- readonly indexes: readonly number[]
102
- readonly start: number
103
- }
104
-
105
- const PATH_WORD_BOUNDARIES = new Set(["-", "_", "."])
106
-
107
- const pathSearchTokens = (query: string) => query.trim().toLowerCase().split(/\s+/).filter((token) => token.length > 0)
108
-
109
- const pathSegments = (path: string): readonly PathSegment[] => {
110
- const parts = path.split("/")
111
- let start = 0
112
- return parts.map((part, index) => {
113
- const segment = {
114
- text: part,
115
- lower: part.toLowerCase(),
116
- start,
117
- index,
118
- isBasename: index === parts.length - 1,
119
- }
120
- start += part.length + 1
121
- return segment
122
- })
123
- }
124
-
125
- const isPathWordBoundary = (segment: string, index: number) => index === 0 || PATH_WORD_BOUNDARIES.has(segment[index - 1] ?? "")
126
-
127
- const fuzzyIndexesFrom = (text: string, token: string, start: number): readonly number[] | null => {
128
- const indexes: number[] = []
129
- let tokenIndex = 0
130
- for (let index = start; index < text.length && tokenIndex < token.length; index++) {
131
- if (text[index] === token[tokenIndex]) {
132
- indexes.push(index)
133
- tokenIndex++
134
- }
135
- }
136
- return tokenIndex === token.length ? indexes : null
137
- }
138
-
139
- const scoreSegmentMatch = (segment: PathSegment, token: string, localIndexes: readonly number[], contiguous: boolean): number => {
140
- const localStart = localIndexes[0] ?? 0
141
- const localEnd = localIndexes[localIndexes.length - 1] ?? localStart
142
- const span = localEnd - localStart + 1
143
- let score = 1000
144
-
145
- if (segment.lower === token) score += 700
146
- else if (contiguous && localStart === 0) score += 460
147
- else if (contiguous && isPathWordBoundary(segment.lower, localStart)) score += 380
148
- else if (contiguous) score += 280
149
- else score += 120
150
-
151
- if (segment.isBasename) score += 320
152
- if (isPathWordBoundary(segment.lower, localStart)) score += 80
153
- if (localStart === 0) score += 60
154
- score += Math.min(segment.index, 8) * 18
155
- score += Math.max(0, 120 - span * 4)
156
- score += Math.max(0, 40 - localStart * 2)
157
-
158
- if (segment.index === 0 && segment.lower === "packages" && segment.lower !== token) score -= 360
159
-
160
- return score
161
- }
162
-
163
- const tokenMatchInSegment = (segment: PathSegment, token: string): FileTokenMatch | null => {
164
- let best: FileTokenMatch | null = null
165
- const addCandidate = (localIndexes: readonly number[], contiguous: boolean) => {
166
- const score = scoreSegmentMatch(segment, token, localIndexes, contiguous)
167
- const start = segment.start + (localIndexes[0] ?? 0)
168
- const indexes = localIndexes.map((index) => segment.start + index)
169
- if (!best || score > best.score || (score === best.score && start < best.start)) {
170
- best = { score, indexes, start }
171
- }
172
- }
173
-
174
- let substringStart = segment.lower.indexOf(token)
175
- while (substringStart >= 0) {
176
- addCandidate(Array.from({ length: token.length }, (_, index) => substringStart + index), true)
177
- substringStart = segment.lower.indexOf(token, substringStart + 1)
178
- }
179
-
180
- for (let index = 0; index < segment.lower.length; index++) {
181
- if (segment.lower[index] !== token[0]) continue
182
- const indexes = fuzzyIndexesFrom(segment.lower, token, index)
183
- if (indexes) addCandidate(indexes, false)
184
- }
185
-
186
- return best
187
- }
188
-
189
- const tokenMatchInPath = (segments: readonly PathSegment[], token: string): FileTokenMatch | null => {
190
- let best: FileTokenMatch | null = null
191
- for (const segment of segments) {
192
- const match = tokenMatchInSegment(segment, token)
193
- if (!match) continue
194
- if (!best || match.score > best.score || (match.score === best.score && match.start < best.start)) {
195
- best = match
196
- }
197
- }
198
- return best
199
- }
200
-
201
- const fuzzyPathMatch = (path: string, query: string): { readonly score: number; readonly matchIndexes: readonly number[] } | null => {
202
- const tokens = pathSearchTokens(query)
203
- if (tokens.length === 0) return { score: 0, matchIndexes: [] }
204
-
205
- const segments = pathSegments(path)
206
- const matchIndexes = new Set<number>()
207
- let score = 0
208
- let previousStart = -1
209
-
210
- for (const token of tokens) {
211
- const match = tokenMatchInPath(segments, token)
212
- if (!match) return null
213
- score += match.score
214
- score += previousStart < 0 || match.start >= previousStart ? 80 : -80
215
- previousStart = match.start
216
- for (const index of match.indexes) matchIndexes.add(index)
217
- }
218
-
219
- return { score, matchIndexes: [...matchIndexes].sort((left, right) => left - right) }
220
- }
221
-
222
- export const filterChangedFiles = (files: readonly DiffFilePatch[], query: string): readonly ChangedFileSearchResult[] => {
223
- const hasQuery = pathSearchTokens(query).length > 0
224
- const results: Array<ChangedFileSearchResult & { readonly score: number }> = []
225
- for (const [index, file] of files.entries()) {
226
- const match = fuzzyPathMatch(file.name, query)
227
- if (match) results.push({ file, index, matchIndexes: match.matchIndexes, score: match.score })
228
- }
229
- if (hasQuery) {
230
- results.sort((left, right) => {
231
- return right.score - left.score || left.index - right.index
232
- })
233
- }
234
- return results
235
- }
236
-
237
- export interface SubmitReviewOption {
238
- readonly event: PullRequestReviewEvent
239
- readonly title: string
240
- readonly description: string
241
- }
242
-
243
- export const submitReviewOptions: readonly SubmitReviewOption[] = [
244
- { event: "COMMENT", title: "Comment", description: "Submit a general review without changing status" },
245
- { event: "APPROVE", title: "Approve", description: "Approve this pull request" },
246
- { event: "REQUEST_CHANGES", title: "Request changes", description: "Block merge until follow-up changes are made" },
247
- ]
248
-
249
- const submitReviewEventColors = {
250
- COMMENT: colors.status.review,
251
- APPROVE: colors.status.passing,
252
- REQUEST_CHANGES: colors.status.failing,
253
- } satisfies Record<PullRequestReviewEvent, string>
254
-
255
- const submitReviewEventColor = (event: PullRequestReviewEvent) => submitReviewEventColors[event]
256
-
257
- export const initialLabelModalState: LabelModalState = {
258
- repository: null,
259
- query: "",
260
- selectedIndex: 0,
261
- availableLabels: [],
262
- loading: false,
263
- }
264
-
265
- export const initialMergeModalState: MergeModalState = {
266
- repository: null,
267
- number: null,
268
- selectedIndex: 0,
269
- loading: false,
270
- running: false,
271
- info: null,
272
- error: null,
273
- }
274
-
275
- export const initialCloseModalState: CloseModalState = {
276
- repository: null,
277
- number: null,
278
- title: "",
279
- url: null,
280
- running: false,
281
- error: null,
282
- }
283
-
284
- export const initialCommentModalState: CommentModalState = {
285
- body: "",
286
- cursor: 0,
287
- error: null,
288
- }
289
-
290
- export const initialCommentThreadModalState: CommentThreadModalState = {
291
- scrollOffset: 0,
292
- }
293
-
294
- export const initialChangedFilesModalState: ChangedFilesModalState = {
295
- query: "",
296
- selectedIndex: 0,
297
- }
298
-
299
- export const initialSubmitReviewModalState: SubmitReviewModalState = {
300
- repository: null,
301
- number: null,
302
- selectedIndex: 0,
303
- body: "",
304
- cursor: 0,
305
- running: false,
306
- error: null,
307
- }
308
-
309
- export const initialThemeModalState: ThemeModalState = {
310
- query: "",
311
- filterMode: false,
312
- initialThemeId: "ghui",
313
- }
314
-
315
- export const initialCommandPaletteState: CommandPaletteState = {
316
- query: "",
317
- selectedIndex: 0,
318
- }
319
-
320
- export const initialOpenRepositoryModalState: OpenRepositoryModalState = {
321
- query: "",
322
- error: null,
323
- }
324
-
325
- export type Modal = Data.TaggedEnum<{
326
- None: {}
327
- Label: LabelModalState
328
- Close: CloseModalState
329
- Merge: MergeModalState
330
- Comment: CommentModalState
331
- CommentThread: CommentThreadModalState
332
- ChangedFiles: ChangedFilesModalState
333
- SubmitReview: SubmitReviewModalState
334
- Theme: ThemeModalState
335
- CommandPalette: CommandPaletteState
336
- OpenRepository: OpenRepositoryModalState
337
- }>
338
-
339
- export const Modal = Data.taggedEnum<Modal>()
340
- export const initialModal: Modal = Modal.None()
341
-
342
- export type ModalTag = Modal["_tag"]
343
- export type ModalState<Tag extends Exclude<ModalTag, "None">> = Omit<Extract<Modal, { _tag: Tag }>, "_tag">
344
-
345
- export const modalInitialStates = {
346
- Label: initialLabelModalState,
347
- Close: initialCloseModalState,
348
- Merge: initialMergeModalState,
349
- Comment: initialCommentModalState,
350
- CommentThread: initialCommentThreadModalState,
351
- ChangedFiles: initialChangedFilesModalState,
352
- SubmitReview: initialSubmitReviewModalState,
353
- Theme: initialThemeModalState,
354
- CommandPalette: initialCommandPaletteState,
355
- OpenRepository: initialOpenRepositoryModalState,
356
- } as const satisfies { [Tag in Exclude<ModalTag, "None">]: ModalState<Tag> }
357
-
358
- export const OpenRepositoryModal = ({
359
- state,
360
- modalWidth,
361
- modalHeight,
362
- offsetLeft,
363
- offsetTop,
364
- }: {
365
- state: OpenRepositoryModalState
366
- modalWidth: number
367
- modalHeight: number
368
- offsetLeft: number
369
- offsetTop: number
370
- }) => {
371
- const { contentWidth } = standardModalDims(modalWidth, modalHeight)
372
- const inputText = state.query.length > 0 ? state.query : "owner/name or GitHub URL"
373
-
374
- return (
375
- <StandardModal
376
- left={offsetLeft}
377
- top={offsetTop}
378
- width={modalWidth}
379
- height={modalHeight}
380
- title="Open Repository"
381
- headerRight={{ text: "owner/name" }}
382
- subtitle={
383
- <TextLine>
384
- <span fg={colors.count}>› </span>
385
- <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(inputText, Math.max(1, contentWidth - 2))}</span>
386
- </TextLine>
387
- }
388
- bodyPadding={1}
389
- footer={<HintRow items={[{ key: "enter", label: "open" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "cancel" }]} />}
390
- >
391
- {state.error ? (
392
- <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} />
393
- ) : (
394
- <PlainLine text={fitCell("Switches to the selected repository view.", contentWidth)} fg={colors.muted} />
395
- )}
396
- </StandardModal>
397
- )
398
- }
399
-
400
- const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
401
- if (!info) return "Loading merge status from GitHub."
402
- if (info.state !== "open") return "This pull request is not open."
403
- if (info.isDraft) return "Draft pull requests cannot be merged."
404
- if (info.mergeable === "conflicting") return "This branch has merge conflicts."
405
- return "No merge actions are currently available."
406
- }
407
-
408
- export const LabelModal = ({
409
- state,
410
- currentLabels,
411
- modalWidth,
412
- modalHeight,
413
- offsetLeft,
414
- offsetTop,
415
- loadingIndicator,
416
- }: {
417
- state: LabelModalState
418
- currentLabels: readonly PullRequestLabel[]
419
- modalWidth: number
420
- modalHeight: number
421
- offsetLeft: number
422
- offsetTop: number
423
- loadingIndicator: string
424
- }) => {
425
- const { bodyHeight: maxVisible, rowWidth } = searchModalDims(modalWidth, modalHeight)
426
- const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
427
- const filtered = filterLabels(state.availableLabels, state.query)
428
- const labelMessageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
429
- const labelMessageBottomRows = Math.max(0, maxVisible - labelMessageTopRows - 1)
430
- const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
431
- const scrollStart = Math.min(
432
- Math.max(0, filtered.length - maxVisible),
433
- Math.max(0, selectedIndex - maxVisible + 1),
434
- )
435
- const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
436
- const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
437
- const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
438
-
439
- return (
440
- <SearchModalFrame
441
- left={offsetLeft}
442
- top={offsetTop}
443
- width={modalWidth}
444
- height={modalHeight}
445
- title={title}
446
- query={state.query}
447
- placeholder="filter labels"
448
- countText={countText}
449
- footer={
450
- <TextLine>
451
- <span fg={colors.count}>↑↓</span>
452
- <span fg={colors.muted}> move </span>
453
- <span fg={colors.count}>esc</span>
454
- <span fg={colors.muted}> close</span>
455
- {filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
456
- </TextLine>
457
- }
458
- >
459
- {state.loading ? (
460
- <>
461
- <Filler rows={labelMessageTopRows} prefix="top" />
462
- <PlainLine text={centerCell(`${loadingIndicator} Loading labels`, rowWidth)} fg={colors.muted} />
463
- <Filler rows={labelMessageBottomRows} prefix="bottom" />
464
- </>
465
- ) : visibleLabels.length === 0 ? (
466
- <>
467
- <Filler rows={labelMessageTopRows} prefix="top" />
468
- <PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", rowWidth)} fg={colors.muted} />
469
- <Filler rows={labelMessageBottomRows} prefix="bottom" />
470
- </>
471
- ) : (
472
- visibleLabels.map((label, index) => {
473
- const actualIndex = scrollStart + index
474
- const isActive = currentNames.has(label.name.toLowerCase())
475
- const isSelected = actualIndex === selectedIndex
476
- const marker = isActive ? "✓" : " "
477
- const nameWidth = Math.max(1, rowWidth - 5)
478
- return (
479
- <box key={label.name} height={1}>
480
- <TextLine bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
481
- <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
482
- <span> </span>
483
- <span bg={labelColor(label)}> </span>
484
- <span> {fitCell(label.name, nameWidth)}</span>
485
- </TextLine>
486
- </box>
487
- )
488
- })
489
- )}
490
- </SearchModalFrame>
491
- )
492
- }
493
-
494
- export const ChangedFilesModal = ({
495
- state,
496
- results,
497
- totalCount,
498
- modalWidth,
499
- modalHeight,
500
- offsetLeft,
501
- offsetTop,
502
- }: {
503
- state: ChangedFilesModalState
504
- results: readonly ChangedFileSearchResult[]
505
- totalCount: number
506
- modalWidth: number
507
- modalHeight: number
508
- offsetLeft: number
509
- offsetTop: number
510
- }) => {
511
- const { bodyHeight: maxVisible, rowWidth } = searchModalDims(modalWidth, modalHeight)
512
- const filtered = results
513
- const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
514
- const scrollStart = Math.min(
515
- Math.max(0, filtered.length - maxVisible),
516
- Math.max(0, selectedIndex - maxVisible + 1),
517
- )
518
- const visibleFiles = filtered.slice(scrollStart, scrollStart + maxVisible)
519
- const title = "Files"
520
- const countText = `${filtered.length}/${totalCount}`
521
- const messageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
522
- const messageBottomRows = Math.max(0, maxVisible - messageTopRows - 1)
523
-
524
- return (
525
- <SearchModalFrame
526
- left={offsetLeft}
527
- top={offsetTop}
528
- width={modalWidth}
529
- height={modalHeight}
530
- title={title}
531
- query={state.query}
532
- placeholder="Filter"
533
- countText={countText}
534
- footer={<HintRow items={[{ key: "↑↓", label: "move" }, { key: "enter", label: "jump" }, { key: "esc", label: "close" }]} />}
535
- >
536
- {visibleFiles.length === 0 ? (
537
- <>
538
- <Filler rows={messageTopRows} prefix="top" />
539
- <PlainLine text={centerCell(state.query.length > 0 ? "No matching files" : "No changed files", rowWidth)} fg={colors.muted} />
540
- <Filler rows={messageBottomRows} prefix="bottom" />
541
- </>
542
- ) : visibleFiles.map((entry, index) => {
543
- const actualIndex = scrollStart + index
544
- const isSelected = actualIndex === selectedIndex
545
- const stats = diffFileStatsText(diffFileStats(entry.file)) || "0"
546
- const statsWidth = Math.min(10, Math.max(3, stats.length))
547
- const nameWidth = Math.max(1, rowWidth - statsWidth)
548
- return (
549
- <TextLine key={`${entry.index}:${entry.file.name}`} width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
550
- <MatchedCell text={entry.file.name} width={nameWidth} query={state.query} matchIndexes={entry.matchIndexes} />
551
- <span fg={colors.muted}>{fitCell(stats, statsWidth, "right")}</span>
552
- </TextLine>
553
- )
554
- })}
555
- </SearchModalFrame>
556
- )
557
- }
558
-
559
- export const MergeModal = ({
560
- state,
561
- modalWidth,
562
- modalHeight,
563
- offsetLeft,
564
- offsetTop,
565
- loadingIndicator,
566
- }: {
567
- state: MergeModalState
568
- modalWidth: number
569
- modalHeight: number
570
- offsetLeft: number
571
- offsetTop: number
572
- loadingIndicator: string
573
- }) => {
574
- const { contentWidth, bodyHeight: optionAreaHeight, rowWidth } = standardModalDims(modalWidth, modalHeight)
575
- const options = availableMergeActions(state.info)
576
- const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
577
- const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
578
- const rightText = state.running ? `${loadingIndicator} running` : state.loading ? `${loadingIndicator} loading` : state.info?.autoMergeEnabled ? "auto on" : "manual"
579
- const repo = state.info?.repository ?? state.repository
580
- const statusLine = state.info
581
- ? `${shortRepoName(state.info.repository)} ${state.info.mergeable} ${state.info.reviewStatus} ${state.info.checkSummary ?? state.info.checkStatus}`
582
- : repo ? shortRepoName(repo) : ""
583
- const optionRows = Math.max(1, Math.floor(optionAreaHeight / 2))
584
- const visibleOptions = options.slice(0, optionRows)
585
- const loadingTopRows = Math.max(0, Math.floor((optionAreaHeight - 1) / 2))
586
- const loadingBottomRows = Math.max(0, optionAreaHeight - loadingTopRows - 1)
587
-
588
- return (
589
- <StandardModal
590
- left={offsetLeft}
591
- top={offsetTop}
592
- width={modalWidth}
593
- height={modalHeight}
594
- title={title}
595
- headerRight={{ text: rightText, pending: state.running || state.loading }}
596
- subtitle={<PlainLine text={fitCell(statusLine, contentWidth)} fg={colors.muted} />}
597
- footer={<HintRow items={[{ key: "↑↓", label: "move" }, { key: "enter", label: "confirm" }, { key: "esc", label: "close" }]} />}
598
- >
599
- {state.loading ? (
600
- <>
601
- <Filler rows={loadingTopRows} prefix="top" />
602
- <PlainLine text={centerCell(`${loadingIndicator} Loading merge status`, rowWidth)} fg={colors.muted} />
603
- <Filler rows={loadingBottomRows} prefix="bottom" />
604
- </>
605
- ) : state.error ? (
606
- <PlainLine text={centerCell(state.error, rowWidth)} fg={colors.error} />
607
- ) : visibleOptions.length === 0 ? (
608
- <PlainLine text={centerCell(mergeUnavailableReason(state.info), rowWidth)} fg={colors.muted} />
609
- ) : (
610
- visibleOptions.map((option, index) => {
611
- const isSelected = index === selectedIndex
612
- const titleColor = option.danger ? colors.error : isSelected ? colors.selectedText : colors.text
613
- const titleWidth = Math.max(1, rowWidth - 1)
614
- const descriptionWidth = Math.max(1, rowWidth - 1)
615
-
616
- return (
617
- <box key={option.action} height={2} flexDirection="column">
618
- <TextLine bg={isSelected ? colors.selectedBg : undefined}>
619
- <span fg={titleColor}> {fitCell(option.title, titleWidth)}</span>
620
- </TextLine>
621
- <TextLine bg={isSelected ? colors.selectedBg : undefined}>
622
- <span fg={colors.muted}> {fitCell(option.description, descriptionWidth)}</span>
623
- </TextLine>
624
- </box>
625
- )
626
- })
627
- )}
628
- </StandardModal>
629
- )
630
- }
631
-
632
- export const CloseModal = ({
633
- state,
634
- modalWidth,
635
- modalHeight,
636
- offsetLeft,
637
- offsetTop,
638
- loadingIndicator,
639
- }: {
640
- state: CloseModalState
641
- modalWidth: number
642
- modalHeight: number
643
- offsetLeft: number
644
- offsetTop: number
645
- loadingIndicator: string
646
- }) => {
647
- const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
648
- const title = state.number ? `Close #${state.number}` : "Close pull request"
649
- const rightText = state.running ? `${loadingIndicator} closing` : "confirm"
650
- const repo = state.repository ? shortRepoName(state.repository) : ""
651
- const titleLines = [fitCell(repo, contentWidth), fitCell(state.title, contentWidth)]
652
- const topRows = Math.max(0, Math.floor((bodyHeight - titleLines.length - 2) / 2))
653
- const bottomRows = Math.max(0, bodyHeight - topRows - titleLines.length - 2)
654
-
655
- return (
656
- <StandardModal
657
- left={offsetLeft}
658
- top={offsetTop}
659
- width={modalWidth}
660
- height={modalHeight}
661
- title={title}
662
- titleFg={colors.error}
663
- headerRight={{ text: rightText, pending: state.running }}
664
- subtitle={<PlainLine text={fitCell("This will close the pull request without merging it.", contentWidth)} fg={colors.muted} />}
665
- bodyPadding={1}
666
- footer={<HintRow items={[{ key: "enter", label: "close" }, { key: "esc", label: "cancel" }]} />}
667
- >
668
- {state.error ? (
669
- <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} />
670
- ) : (
671
- <>
672
- <Filler rows={topRows} prefix="top" />
673
- <PlainLine text={titleLines[0]!} fg={colors.muted} />
674
- <PlainLine text={titleLines[1]!} fg={colors.text} bold />
675
- <Filler rows={bottomRows} prefix="bottom" />
676
- </>
677
- )}
678
- </StandardModal>
679
- )
680
- }
681
-
682
- export const CommentModal = ({
683
- state,
684
- anchorLabel,
685
- modalWidth,
686
- modalHeight,
687
- offsetLeft,
688
- offsetTop,
689
- }: {
690
- state: CommentModalState
691
- anchorLabel: string
692
- modalWidth: number
693
- modalHeight: number
694
- offsetLeft: number
695
- offsetTop: number
696
- }) => {
697
- const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
698
- const title = "Comment"
699
- const editorHeight = Math.max(1, bodyHeight - (state.error ? 1 : 0))
700
- const lineRanges = commentEditorLines(state.body)
701
- const cursor = clampCursor(state.body, state.cursor)
702
- const cursorLineIndex = cursorLineIndexForLines(lineRanges, cursor)
703
- const visibleStart = Math.min(
704
- Math.max(0, lineRanges.length - editorHeight),
705
- Math.max(0, cursorLineIndex - editorHeight + 1),
706
- )
707
- const visibleLines = lineRanges.slice(visibleStart, visibleStart + editorHeight)
708
- const renderEditorLine = (line: { readonly text: string; readonly start: number; readonly end: number }, index: number) => {
709
- const lineIndex = visibleStart + index
710
- const isCursorLine = lineIndex === cursorLineIndex
711
- const cursorColumn = Math.max(0, Math.min(cursor - line.start, line.text.length))
712
- const viewStart = isCursorLine ? Math.max(0, cursorColumn - contentWidth + 1) : 0
713
- const visibleText = line.text.slice(viewStart, viewStart + contentWidth)
714
-
715
- if (!isCursorLine) {
716
- return <PlainLine key={lineIndex} text={fitCell(visibleText, contentWidth)} fg={state.body.length > 0 ? colors.text : colors.muted} />
717
- }
718
-
719
- const cursorInView = cursorColumn - viewStart
720
- const before = visibleText.slice(0, cursorInView)
721
- const placeholder = state.body.length === 0 ? "Write a comment..." : ""
722
- const cursorChar = placeholder ? placeholder[0] ?? " " : visibleText[cursorInView] ?? " "
723
- const after = placeholder ? placeholder.slice(1) : visibleText.slice(cursorInView + 1)
724
-
725
- return (
726
- <TextLine key={lineIndex}>
727
- {before ? <span fg={colors.text}>{before}</span> : null}
728
- <span bg={colors.accent} fg={colors.background}>{cursorChar}</span>
729
- {after ? <span fg={placeholder ? colors.muted : colors.text}>{after}</span> : null}
730
- </TextLine>
731
- )
732
- }
733
-
734
- return (
735
- <StandardModal
736
- left={offsetLeft}
737
- top={offsetTop}
738
- width={modalWidth}
739
- height={modalHeight}
740
- title={title}
741
- headerRight={{ text: "enter save" }}
742
- subtitle={<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />}
743
- bodyPadding={1}
744
- footer={<HintRow items={[{ key: "enter", label: "save" }, { key: "shift-enter", label: "newline" }, { key: "esc", label: "cancel" }]} />}
745
- >
746
- {state.error ? <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} /> : null}
747
- {visibleLines.map(renderEditorLine)}
748
- </StandardModal>
749
- )
750
- }
751
-
752
- export const SubmitReviewModal = ({
753
- state,
754
- modalWidth,
755
- modalHeight,
756
- offsetLeft,
757
- offsetTop,
758
- loadingIndicator,
759
- }: {
760
- state: SubmitReviewModalState
761
- modalWidth: number
762
- modalHeight: number
763
- offsetLeft: number
764
- offsetTop: number
765
- loadingIndicator: string
766
- }) => {
767
- const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
768
- const selectedIndex = Math.max(0, Math.min(state.selectedIndex, submitReviewOptions.length - 1))
769
- const editorHeight = Math.max(1, bodyHeight - submitReviewOptions.length - (state.error ? 1 : 0))
770
- const lineRanges = commentEditorLines(state.body)
771
- const cursor = clampCursor(state.body, state.cursor)
772
- const cursorLineIndex = cursorLineIndexForLines(lineRanges, cursor)
773
- const visibleStart = Math.min(
774
- Math.max(0, lineRanges.length - editorHeight),
775
- Math.max(0, cursorLineIndex - editorHeight + 1),
776
- )
777
- const visibleLines = lineRanges.slice(visibleStart, visibleStart + editorHeight)
778
- const title = state.number ? `Submit review #${state.number}` : "Submit review"
779
- const rightText = state.running ? `${loadingIndicator} submitting` : submitReviewOptions[selectedIndex]?.title ?? "review"
780
- const subtitleText = state.repository ? shortRepoName(state.repository) : "Choose a review action and optional summary"
781
- const renderEditorLine = (line: { readonly text: string; readonly start: number; readonly end: number }, index: number) => {
782
- const lineIndex = visibleStart + index
783
- const isCursorLine = lineIndex === cursorLineIndex
784
- const cursorColumn = Math.max(0, Math.min(cursor - line.start, line.text.length))
785
- const viewStart = isCursorLine ? Math.max(0, cursorColumn - contentWidth + 1) : 0
786
- const visibleText = line.text.slice(viewStart, viewStart + contentWidth)
787
-
788
- if (!isCursorLine) {
789
- return <PlainLine key={lineIndex} text={fitCell(visibleText, contentWidth)} fg={state.body.length > 0 ? colors.text : colors.muted} />
790
- }
791
-
792
- const cursorInView = cursorColumn - viewStart
793
- const before = visibleText.slice(0, cursorInView)
794
- const placeholder = state.body.length === 0 ? "Optional review summary..." : ""
795
- const cursorChar = placeholder ? placeholder[0] ?? " " : visibleText[cursorInView] ?? " "
796
- const after = placeholder ? placeholder.slice(1) : visibleText.slice(cursorInView + 1)
797
-
798
- return (
799
- <TextLine key={lineIndex}>
800
- {before ? <span fg={colors.text}>{before}</span> : null}
801
- <span bg={colors.accent} fg={colors.background}>{cursorChar}</span>
802
- {after ? <span fg={placeholder ? colors.muted : colors.text}>{after}</span> : null}
803
- </TextLine>
804
- )
805
- }
806
-
807
- return (
808
- <StandardModal
809
- left={offsetLeft}
810
- top={offsetTop}
811
- width={modalWidth}
812
- height={modalHeight}
813
- title={title}
814
- headerRight={{ text: rightText, pending: state.running }}
815
- subtitle={<PlainLine text={fitCell(subtitleText, contentWidth)} fg={colors.muted} />}
816
- bodyPadding={1}
817
- footer={<HintRow items={[{ key: "tab", label: "action" }, { key: "enter", label: "submit" }, { key: "shift-enter", label: "newline" }, { key: "esc", label: "cancel" }]} />}
818
- >
819
- {submitReviewOptions.map((option, index) => {
820
- const isSelected = index === selectedIndex
821
- const titleWidth = Math.min(18, Math.max(8, contentWidth - 8))
822
- const descriptionWidth = Math.max(1, contentWidth - titleWidth - 4)
823
- return (
824
- <TextLine key={option.event} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
825
- <span fg={submitReviewEventColor(option.event)}>{isSelected ? "›" : " "}</span>
826
- <span> {fitCell(option.title, titleWidth)}</span>
827
- <span fg={isSelected ? colors.selectedText : colors.muted}>{fitCell(option.description, descriptionWidth)}</span>
828
- </TextLine>
829
- )
830
- })}
831
- {state.error ? <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} /> : null}
832
- {visibleLines.map(renderEditorLine)}
833
- </StandardModal>
834
- )
835
- }
836
-
837
- const commentThreadRows = (comments: readonly PullRequestReviewComment[], width: number): readonly CommentDisplayLine[] =>
838
- comments.flatMap((comment) => commentDisplayRows({ item: comment, width }))
839
-
840
- export const CommentThreadModal = ({
841
- state,
842
- anchorLabel,
843
- comments,
844
- modalWidth,
845
- modalHeight,
846
- offsetLeft,
847
- offsetTop,
848
- }: {
849
- state: CommentThreadModalState
850
- anchorLabel: string
851
- comments: readonly PullRequestReviewComment[]
852
- modalWidth: number
853
- modalHeight: number
854
- offsetLeft: number
855
- offsetTop: number
856
- }) => {
857
- const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
858
- const title = "Thread"
859
- const countText = comments.length === 1 ? "1 comment" : `${comments.length} comments`
860
- const rows = commentThreadRows(comments, contentWidth)
861
- const maxScroll = Math.max(0, rows.length - bodyHeight)
862
- const scrollOffset = Math.max(0, Math.min(state.scrollOffset, maxScroll))
863
- const visibleRows = rows.slice(scrollOffset, scrollOffset + bodyHeight)
864
-
865
- return (
866
- <StandardModal
867
- left={offsetLeft}
868
- top={offsetTop}
869
- width={modalWidth}
870
- height={modalHeight}
871
- title={title}
872
- headerRight={{ text: countText }}
873
- subtitle={<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />}
874
- bodyPadding={1}
875
- footer={<HintRow items={[{ key: "↑↓", label: "scroll" }, { key: "enter", label: "comment" }, { key: "esc", label: "close" }]} />}
876
- >
877
- {visibleRows.length === 0 ? (
878
- <PlainLine text={fitCell("No comments on this line.", contentWidth)} fg={colors.muted} />
879
- ) : visibleRows.map((row) => <CommentSegmentsLine key={row.key} segments={row.segments} />)}
880
- </StandardModal>
881
- )
882
- }
883
-
884
- export const ThemeModal = ({
885
- state,
886
- activeThemeId,
887
- modalWidth,
888
- modalHeight,
889
- offsetLeft,
890
- offsetTop,
891
- }: {
892
- state: ThemeModalState
893
- activeThemeId: ThemeId
894
- modalWidth: number
895
- modalHeight: number
896
- offsetLeft: number
897
- offsetTop: number
898
- }) => {
899
- const { contentWidth, bodyHeight: maxVisible, rowWidth } = standardModalDims(modalWidth, modalHeight)
900
- const filteredThemes = filterThemeDefinitions(state.query)
901
- const activeIndex = filteredThemes.findIndex((theme) => theme.id === activeThemeId)
902
- const selectedIndex = Math.max(0, activeIndex)
903
- const selectedTheme = filteredThemes[selectedIndex] ?? themeDefinitions.find((theme) => theme.id === activeThemeId) ?? themeDefinitions[0]!
904
- const scrollStart = Math.min(
905
- Math.max(0, filteredThemes.length - maxVisible),
906
- Math.max(0, selectedIndex - maxVisible + 1),
907
- )
908
- const visibleThemes = filteredThemes.slice(scrollStart, scrollStart + maxVisible)
909
- const countText = `${filteredThemes.length === 0 ? 0 : selectedIndex + 1}/${filteredThemes.length}`
910
- const subtitleText = state.filterMode ? (state.query.length > 0 ? state.query : "type to filter themes") : selectedTheme.description
911
- const queryPrefix = "/ "
912
- const subtitleWidth = Math.max(1, contentWidth - (state.filterMode ? queryPrefix.length : 0))
913
- const messageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
914
- const messageBottomRows = Math.max(0, maxVisible - messageTopRows - 1)
915
-
916
- return (
917
- <StandardModal
918
- left={offsetLeft}
919
- top={offsetTop}
920
- width={modalWidth}
921
- height={modalHeight}
922
- title="Themes"
923
- headerRight={{ text: countText }}
924
- subtitle={state.filterMode ? (
925
- <TextLine>
926
- <span fg={colors.count}>{queryPrefix}</span>
927
- <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(subtitleText, subtitleWidth)}</span>
928
- </TextLine>
929
- ) : (
930
- <PlainLine text={fitCell(subtitleText, subtitleWidth)} fg={colors.muted} />
931
- )}
932
- footer={<HintRow items={[{ key: "↑↓", label: "preview" }, { key: "/", label: "filter" }, { key: "enter", label: "select" }, { key: "esc", label: "cancel" }]} />}
933
- >
934
- {visibleThemes.length === 0 ? (
935
- <>
936
- <Filler rows={messageTopRows} prefix="top" />
937
- <PlainLine text={centerCell("No matching themes", rowWidth)} fg={colors.muted} />
938
- <Filler rows={messageBottomRows} prefix="bottom" />
939
- </>
940
- ) : visibleThemes.map((theme, index) => {
941
- const actualIndex = scrollStart + index
942
- const isSelected = actualIndex === selectedIndex
943
- const isActive = theme.id === activeThemeId
944
- const marker = isActive ? "✓" : " "
945
- const swatchWidth = 6
946
- const nameWidth = Math.max(1, rowWidth - swatchWidth - 3)
947
-
948
- return (
949
- <TextLine key={theme.id} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
950
- <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
951
- <span> </span>
952
- <span>{fitCell(theme.name, nameWidth)}</span>
953
- <span bg={theme.colors.background}> </span>
954
- <span bg={theme.colors.modalBackground}> </span>
955
- <span bg={theme.colors.accent}> </span>
956
- <span bg={theme.colors.status.passing}> </span>
957
- <span bg={theme.colors.status.failing}> </span>
958
- <span bg={theme.colors.status.review}> </span>
959
- </TextLine>
960
- )
961
- })}
962
- </StandardModal>
963
- )
964
- }