@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.
- package/README.md +13 -3
- package/bin/ghui.js +6 -1
- package/dist/index.js +9456 -0
- package/package.json +7 -4
- package/src/App.tsx +0 -2779
- package/src/appCommands.ts +0 -409
- package/src/commands.ts +0 -73
- package/src/config.ts +0 -20
- package/src/date.ts +0 -20
- package/src/domain.ts +0 -149
- package/src/errors.ts +0 -10
- package/src/index.tsx +0 -50
- package/src/keyboard/opentuiAdapter.ts +0 -43
- package/src/keymap/all.ts +0 -116
- package/src/keymap/changedFilesModal.ts +0 -23
- package/src/keymap/closeModal.ts +0 -13
- package/src/keymap/commandPalette.ts +0 -16
- package/src/keymap/commentModal.ts +0 -73
- package/src/keymap/commentThreadModal.ts +0 -24
- package/src/keymap/detailView.ts +0 -30
- package/src/keymap/diffView.ts +0 -93
- package/src/keymap/filterMode.ts +0 -13
- package/src/keymap/helpers.ts +0 -24
- package/src/keymap/labelModal.ts +0 -16
- package/src/keymap/listNav.ts +0 -119
- package/src/keymap/mergeModal.ts +0 -23
- package/src/keymap/openRepositoryModal.ts +0 -13
- package/src/keymap/submitReviewModal.ts +0 -48
- package/src/keymap/themeModal.ts +0 -49
- package/src/mergeActions.ts +0 -88
- package/src/observability.ts +0 -46
- package/src/pullRequestCache.ts +0 -19
- package/src/pullRequestViews.ts +0 -43
- package/src/services/BrowserOpener.ts +0 -22
- package/src/services/Clipboard.ts +0 -46
- package/src/services/CommandRunner.ts +0 -91
- package/src/services/GitHubService.ts +0 -756
- package/src/services/MockGitHubService.ts +0 -182
- package/src/themeStore.ts +0 -60
- package/src/ui/CommandPalette.tsx +0 -176
- package/src/ui/DetailsPane.tsx +0 -656
- package/src/ui/FooterHints.tsx +0 -90
- package/src/ui/LoadingLogo.tsx +0 -75
- package/src/ui/PullRequestDiffPane.tsx +0 -249
- package/src/ui/PullRequestList.tsx +0 -194
- package/src/ui/colors.ts +0 -821
- package/src/ui/commentEditor.ts +0 -126
- package/src/ui/comments.tsx +0 -143
- package/src/ui/diff.ts +0 -650
- package/src/ui/diffStats.tsx +0 -25
- package/src/ui/modals.tsx +0 -964
- package/src/ui/primitives.tsx +0 -350
- package/src/ui/pullRequests.ts +0 -106
- package/src/ui/singleLineInput.ts +0 -26
- 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
|
-
}
|