@kitlangton/ghui 0.2.1 → 0.3.2

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 (53) hide show
  1. package/README.md +1 -4
  2. package/bin/ghui.js +6 -1
  3. package/dist/index.js +9419 -0
  4. package/package.json +7 -4
  5. package/src/App.tsx +0 -2604
  6. package/src/appCommands.ts +0 -384
  7. package/src/commands.ts +0 -73
  8. package/src/config.ts +0 -28
  9. package/src/date.ts +0 -20
  10. package/src/domain.ts +0 -139
  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 -103
  15. package/src/keymap/closeModal.ts +0 -13
  16. package/src/keymap/commandPalette.ts +0 -16
  17. package/src/keymap/commentModal.ts +0 -73
  18. package/src/keymap/commentThreadModal.ts +0 -24
  19. package/src/keymap/detailView.ts +0 -30
  20. package/src/keymap/diffView.ts +0 -91
  21. package/src/keymap/filterMode.ts +0 -13
  22. package/src/keymap/helpers.ts +0 -24
  23. package/src/keymap/labelModal.ts +0 -16
  24. package/src/keymap/listNav.ts +0 -119
  25. package/src/keymap/mergeModal.ts +0 -23
  26. package/src/keymap/openRepositoryModal.ts +0 -13
  27. package/src/keymap/themeModal.ts +0 -49
  28. package/src/mergeActions.ts +0 -88
  29. package/src/observability.ts +0 -46
  30. package/src/pullRequestCache.ts +0 -19
  31. package/src/pullRequestViews.ts +0 -45
  32. package/src/services/BrowserOpener.ts +0 -22
  33. package/src/services/Clipboard.ts +0 -46
  34. package/src/services/CommandRunner.ts +0 -91
  35. package/src/services/GitHubService.ts +0 -741
  36. package/src/services/MockGitHubService.ts +0 -181
  37. package/src/themeStore.ts +0 -60
  38. package/src/ui/CommandPalette.tsx +0 -216
  39. package/src/ui/DetailsPane.tsx +0 -656
  40. package/src/ui/FooterHints.tsx +0 -90
  41. package/src/ui/LoadingLogo.tsx +0 -75
  42. package/src/ui/PullRequestDiffPane.tsx +0 -248
  43. package/src/ui/PullRequestList.tsx +0 -210
  44. package/src/ui/colors.ts +0 -814
  45. package/src/ui/commentEditor.ts +0 -126
  46. package/src/ui/comments.tsx +0 -143
  47. package/src/ui/diff.ts +0 -650
  48. package/src/ui/diffStats.tsx +0 -25
  49. package/src/ui/modals.tsx +0 -614
  50. package/src/ui/primitives.tsx +0 -205
  51. package/src/ui/pullRequests.ts +0 -106
  52. package/src/ui/singleLineInput.ts +0 -26
  53. package/src/ui/spinner.ts +0 -1
package/src/ui/modals.tsx DELETED
@@ -1,614 +0,0 @@
1
- import { Data } from "effect"
2
- import type { PullRequestLabel, PullRequestMergeInfo, PullRequestReviewComment } 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 { centerCell, Filler, fitCell, HintRow, PlainLine, StandardModal, standardModalDims, TextLine } from "./primitives.js"
8
- import { labelColor, shortRepoName } from "./pullRequests.js"
9
-
10
- export interface LabelModalState {
11
- readonly repository: string | null
12
- readonly query: string
13
- readonly selectedIndex: number
14
- readonly availableLabels: readonly PullRequestLabel[]
15
- readonly loading: boolean
16
- }
17
-
18
- export interface MergeModalState {
19
- readonly repository: string | null
20
- readonly number: number | null
21
- readonly selectedIndex: number
22
- readonly loading: boolean
23
- readonly running: boolean
24
- readonly info: PullRequestMergeInfo | null
25
- readonly error: string | null
26
- }
27
-
28
- export interface CloseModalState {
29
- readonly repository: string | null
30
- readonly number: number | null
31
- readonly title: string
32
- readonly url: string | null
33
- readonly running: boolean
34
- readonly error: string | null
35
- }
36
-
37
- export interface CommentModalState {
38
- readonly body: string
39
- readonly cursor: number
40
- readonly error: string | null
41
- }
42
-
43
- export interface CommentThreadModalState {
44
- readonly scrollOffset: number
45
- }
46
-
47
- export interface ThemeModalState {
48
- readonly query: string
49
- readonly filterMode: boolean
50
- readonly initialThemeId: ThemeId
51
- }
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
-
69
- export const initialLabelModalState: LabelModalState = {
70
- repository: null,
71
- query: "",
72
- selectedIndex: 0,
73
- availableLabels: [],
74
- loading: false,
75
- }
76
-
77
- export const initialMergeModalState: MergeModalState = {
78
- repository: null,
79
- number: null,
80
- selectedIndex: 0,
81
- loading: false,
82
- running: false,
83
- info: null,
84
- error: null,
85
- }
86
-
87
- export const initialCloseModalState: CloseModalState = {
88
- repository: null,
89
- number: null,
90
- title: "",
91
- url: null,
92
- running: false,
93
- error: null,
94
- }
95
-
96
- export const initialCommentModalState: CommentModalState = {
97
- body: "",
98
- cursor: 0,
99
- error: null,
100
- }
101
-
102
- export const initialCommentThreadModalState: CommentThreadModalState = {
103
- scrollOffset: 0,
104
- }
105
-
106
- export const initialThemeModalState: ThemeModalState = {
107
- query: "",
108
- filterMode: false,
109
- initialThemeId: "ghui",
110
- }
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
-
122
- export type Modal = Data.TaggedEnum<{
123
- None: {}
124
- Label: LabelModalState
125
- Close: CloseModalState
126
- Merge: MergeModalState
127
- Comment: CommentModalState
128
- CommentThread: CommentThreadModalState
129
- Theme: ThemeModalState
130
- CommandPalette: CommandPaletteState
131
- OpenRepository: OpenRepositoryModalState
132
- }>
133
-
134
- export const Modal = Data.taggedEnum<Modal>()
135
- export const initialModal: Modal = Modal.None()
136
-
137
- export type ModalTag = Modal["_tag"]
138
- export type ModalState<Tag extends Exclude<ModalTag, "None">> = Omit<Extract<Modal, { _tag: Tag }>, "_tag">
139
-
140
- export const modalInitialStates = {
141
- Label: initialLabelModalState,
142
- Close: initialCloseModalState,
143
- Merge: initialMergeModalState,
144
- Comment: initialCommentModalState,
145
- CommentThread: initialCommentThreadModalState,
146
- Theme: initialThemeModalState,
147
- CommandPalette: initialCommandPaletteState,
148
- OpenRepository: initialOpenRepositoryModalState,
149
- } as const satisfies { [Tag in Exclude<ModalTag, "None">]: ModalState<Tag> }
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
-
193
- const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
194
- if (!info) return "Loading merge status from GitHub."
195
- if (info.state !== "open") return "This pull request is not open."
196
- if (info.isDraft) return "Draft pull requests cannot be merged."
197
- if (info.mergeable === "conflicting") return "This branch has merge conflicts."
198
- return "No merge actions are currently available."
199
- }
200
-
201
- export const LabelModal = ({
202
- state,
203
- currentLabels,
204
- modalWidth,
205
- modalHeight,
206
- offsetLeft,
207
- offsetTop,
208
- loadingIndicator,
209
- }: {
210
- state: LabelModalState
211
- currentLabels: readonly PullRequestLabel[]
212
- modalWidth: number
213
- modalHeight: number
214
- offsetLeft: number
215
- offsetTop: number
216
- loadingIndicator: string
217
- }) => {
218
- const { contentWidth, bodyHeight: maxVisible, rowWidth } = standardModalDims(modalWidth, modalHeight)
219
- const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
220
- const filtered = filterLabels(state.availableLabels, state.query)
221
- const labelMessageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
222
- const labelMessageBottomRows = Math.max(0, maxVisible - labelMessageTopRows - 1)
223
- const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
224
- const scrollStart = Math.min(
225
- Math.max(0, filtered.length - maxVisible),
226
- Math.max(0, selectedIndex - maxVisible + 1),
227
- )
228
- const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
229
- const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
230
- const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
231
- const queryText = state.query.length > 0 ? state.query : "type to filter labels"
232
- const queryPrefix = "/ "
233
- const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
234
-
235
- return (
236
- <StandardModal
237
- left={offsetLeft}
238
- top={offsetTop}
239
- width={modalWidth}
240
- height={modalHeight}
241
- title={title}
242
- headerRight={{ text: countText }}
243
- subtitle={
244
- <TextLine>
245
- <span fg={colors.count}>{queryPrefix}</span>
246
- <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
247
- </TextLine>
248
- }
249
- footer={
250
- <TextLine>
251
- <span fg={colors.count}>↑↓</span>
252
- <span fg={colors.muted}> move </span>
253
- <span fg={colors.count}>esc</span>
254
- <span fg={colors.muted}> close</span>
255
- {filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
256
- </TextLine>
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>
291
- )
292
- }
293
-
294
- export const MergeModal = ({
295
- state,
296
- modalWidth,
297
- modalHeight,
298
- offsetLeft,
299
- offsetTop,
300
- loadingIndicator,
301
- }: {
302
- state: MergeModalState
303
- modalWidth: number
304
- modalHeight: number
305
- offsetLeft: number
306
- offsetTop: number
307
- loadingIndicator: string
308
- }) => {
309
- const { contentWidth, bodyHeight: optionAreaHeight, rowWidth } = standardModalDims(modalWidth, modalHeight)
310
- const options = availableMergeActions(state.info)
311
- const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
312
- const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
313
- const rightText = state.running ? `${loadingIndicator} running` : state.loading ? `${loadingIndicator} loading` : state.info?.autoMergeEnabled ? "auto on" : "manual"
314
- const repo = state.info?.repository ?? state.repository
315
- const statusLine = state.info
316
- ? `${shortRepoName(state.info.repository)} ${state.info.mergeable} ${state.info.reviewStatus} ${state.info.checkSummary ?? state.info.checkStatus}`
317
- : repo ? shortRepoName(repo) : ""
318
- const optionRows = Math.max(1, Math.floor(optionAreaHeight / 2))
319
- const visibleOptions = options.slice(0, optionRows)
320
- const loadingTopRows = Math.max(0, Math.floor((optionAreaHeight - 1) / 2))
321
- const loadingBottomRows = Math.max(0, optionAreaHeight - loadingTopRows - 1)
322
-
323
- return (
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>
364
- )
365
- }
366
-
367
- export const CloseModal = ({
368
- state,
369
- modalWidth,
370
- modalHeight,
371
- offsetLeft,
372
- offsetTop,
373
- loadingIndicator,
374
- }: {
375
- state: CloseModalState
376
- modalWidth: number
377
- modalHeight: number
378
- offsetLeft: number
379
- offsetTop: number
380
- loadingIndicator: string
381
- }) => {
382
- const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
383
- const title = state.number ? `Close #${state.number}` : "Close pull request"
384
- const rightText = state.running ? `${loadingIndicator} closing` : "confirm"
385
- const repo = state.repository ? shortRepoName(state.repository) : ""
386
- const titleLines = [fitCell(repo, contentWidth), fitCell(state.title, contentWidth)]
387
- const topRows = Math.max(0, Math.floor((bodyHeight - titleLines.length - 2) / 2))
388
- const bottomRows = Math.max(0, bodyHeight - topRows - titleLines.length - 2)
389
-
390
- return (
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>
414
- )
415
- }
416
-
417
- export const CommentModal = ({
418
- state,
419
- anchorLabel,
420
- modalWidth,
421
- modalHeight,
422
- offsetLeft,
423
- offsetTop,
424
- }: {
425
- state: CommentModalState
426
- anchorLabel: string
427
- modalWidth: number
428
- modalHeight: number
429
- offsetLeft: number
430
- offsetTop: number
431
- }) => {
432
- const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
433
- const title = "Comment"
434
- const editorHeight = Math.max(1, bodyHeight - (state.error ? 1 : 0))
435
- const lineRanges = commentEditorLines(state.body)
436
- const cursor = clampCursor(state.body, state.cursor)
437
- const cursorLineIndex = cursorLineIndexForLines(lineRanges, cursor)
438
- const visibleStart = Math.min(
439
- Math.max(0, lineRanges.length - editorHeight),
440
- Math.max(0, cursorLineIndex - editorHeight + 1),
441
- )
442
- const visibleLines = lineRanges.slice(visibleStart, visibleStart + editorHeight)
443
- const renderEditorLine = (line: { readonly text: string; readonly start: number; readonly end: number }, index: number) => {
444
- const lineIndex = visibleStart + index
445
- const isCursorLine = lineIndex === cursorLineIndex
446
- const cursorColumn = Math.max(0, Math.min(cursor - line.start, line.text.length))
447
- const viewStart = isCursorLine ? Math.max(0, cursorColumn - contentWidth + 1) : 0
448
- const visibleText = line.text.slice(viewStart, viewStart + contentWidth)
449
-
450
- if (!isCursorLine) {
451
- return <PlainLine key={lineIndex} text={fitCell(visibleText, contentWidth)} fg={state.body.length > 0 ? colors.text : colors.muted} />
452
- }
453
-
454
- const cursorInView = cursorColumn - viewStart
455
- const before = visibleText.slice(0, cursorInView)
456
- const placeholder = state.body.length === 0 ? "Write a comment..." : ""
457
- const cursorChar = placeholder ? placeholder[0] ?? " " : visibleText[cursorInView] ?? " "
458
- const after = placeholder ? placeholder.slice(1) : visibleText.slice(cursorInView + 1)
459
-
460
- return (
461
- <TextLine key={lineIndex}>
462
- {before ? <span fg={colors.text}>{before}</span> : null}
463
- <span bg={colors.accent} fg={colors.background}>{cursorChar}</span>
464
- {after ? <span fg={placeholder ? colors.muted : colors.text}>{after}</span> : null}
465
- </TextLine>
466
- )
467
- }
468
-
469
- return (
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>
484
- )
485
- }
486
-
487
- const commentThreadRows = (comments: readonly PullRequestReviewComment[], width: number): readonly CommentDisplayLine[] =>
488
- comments.flatMap((comment) => commentDisplayRows({ item: comment, width }))
489
-
490
- export const CommentThreadModal = ({
491
- state,
492
- anchorLabel,
493
- comments,
494
- modalWidth,
495
- modalHeight,
496
- offsetLeft,
497
- offsetTop,
498
- }: {
499
- state: CommentThreadModalState
500
- anchorLabel: string
501
- comments: readonly PullRequestReviewComment[]
502
- modalWidth: number
503
- modalHeight: number
504
- offsetLeft: number
505
- offsetTop: number
506
- }) => {
507
- const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
508
- const title = "Thread"
509
- const countText = comments.length === 1 ? "1 comment" : `${comments.length} comments`
510
- const rows = commentThreadRows(comments, contentWidth)
511
- const maxScroll = Math.max(0, rows.length - bodyHeight)
512
- const scrollOffset = Math.max(0, Math.min(state.scrollOffset, maxScroll))
513
- const visibleRows = rows.slice(scrollOffset, scrollOffset + bodyHeight)
514
-
515
- return (
516
- <StandardModal
517
- left={offsetLeft}
518
- top={offsetTop}
519
- width={modalWidth}
520
- height={modalHeight}
521
- title={title}
522
- headerRight={{ text: countText }}
523
- subtitle={<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />}
524
- bodyPadding={1}
525
- footer={<HintRow items={[{ key: "↑↓", label: "scroll" }, { key: "a", label: "comment" }, { key: "esc", label: "close" }]} />}
526
- >
527
- {visibleRows.length === 0 ? (
528
- <PlainLine text={fitCell("No comments on this line.", contentWidth)} fg={colors.muted} />
529
- ) : visibleRows.map((row) => <CommentSegmentsLine key={row.key} segments={row.segments} />)}
530
- </StandardModal>
531
- )
532
- }
533
-
534
- export const ThemeModal = ({
535
- state,
536
- activeThemeId,
537
- modalWidth,
538
- modalHeight,
539
- offsetLeft,
540
- offsetTop,
541
- }: {
542
- state: ThemeModalState
543
- activeThemeId: ThemeId
544
- modalWidth: number
545
- modalHeight: number
546
- offsetLeft: number
547
- offsetTop: number
548
- }) => {
549
- const { contentWidth, bodyHeight: maxVisible, rowWidth } = standardModalDims(modalWidth, modalHeight)
550
- const filteredThemes = filterThemeDefinitions(state.query)
551
- const activeIndex = filteredThemes.findIndex((theme) => theme.id === activeThemeId)
552
- const selectedIndex = Math.max(0, activeIndex)
553
- const selectedTheme = filteredThemes[selectedIndex] ?? themeDefinitions.find((theme) => theme.id === activeThemeId) ?? themeDefinitions[0]!
554
- const scrollStart = Math.min(
555
- Math.max(0, filteredThemes.length - maxVisible),
556
- Math.max(0, selectedIndex - maxVisible + 1),
557
- )
558
- const visibleThemes = filteredThemes.slice(scrollStart, scrollStart + maxVisible)
559
- const countText = `${filteredThemes.length === 0 ? 0 : selectedIndex + 1}/${filteredThemes.length}`
560
- const subtitleText = state.filterMode ? (state.query.length > 0 ? state.query : "type to filter themes") : selectedTheme.description
561
- const queryPrefix = "/ "
562
- const subtitleWidth = Math.max(1, contentWidth - (state.filterMode ? queryPrefix.length : 0))
563
- const messageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
564
- const messageBottomRows = Math.max(0, maxVisible - messageTopRows - 1)
565
-
566
- return (
567
- <StandardModal
568
- left={offsetLeft}
569
- top={offsetTop}
570
- width={modalWidth}
571
- height={modalHeight}
572
- title="Themes"
573
- headerRight={{ text: countText }}
574
- subtitle={state.filterMode ? (
575
- <TextLine>
576
- <span fg={colors.count}>{queryPrefix}</span>
577
- <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(subtitleText, subtitleWidth)}</span>
578
- </TextLine>
579
- ) : (
580
- <PlainLine text={fitCell(subtitleText, subtitleWidth)} fg={colors.muted} />
581
- )}
582
- footer={<HintRow items={[{ key: "↑↓", label: "preview" }, { key: "/", label: "filter" }, { key: "enter", label: "select" }, { key: "esc", label: "cancel" }]} />}
583
- >
584
- {visibleThemes.length === 0 ? (
585
- <>
586
- <Filler rows={messageTopRows} prefix="top" />
587
- <PlainLine text={centerCell("No matching themes", rowWidth)} fg={colors.muted} />
588
- <Filler rows={messageBottomRows} prefix="bottom" />
589
- </>
590
- ) : visibleThemes.map((theme, index) => {
591
- const actualIndex = scrollStart + index
592
- const isSelected = actualIndex === selectedIndex
593
- const isActive = theme.id === activeThemeId
594
- const marker = isActive ? "✓" : " "
595
- const swatchWidth = 6
596
- const nameWidth = Math.max(1, rowWidth - swatchWidth - 3)
597
-
598
- return (
599
- <TextLine key={theme.id} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
600
- <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
601
- <span> </span>
602
- <span>{fitCell(theme.name, nameWidth)}</span>
603
- <span bg={theme.colors.background}> </span>
604
- <span bg={theme.colors.modalBackground}> </span>
605
- <span bg={theme.colors.accent}> </span>
606
- <span bg={theme.colors.status.passing}> </span>
607
- <span bg={theme.colors.status.failing}> </span>
608
- <span bg={theme.colors.status.review}> </span>
609
- </TextLine>
610
- )
611
- })}
612
- </StandardModal>
613
- )
614
- }