@kitlangton/ghui 0.2.1 → 0.3.0
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 +1 -4
- package/package.json +1 -1
- package/src/App.tsx +189 -14
- package/src/appCommands.ts +28 -3
- package/src/config.ts +0 -8
- package/src/domain.ts +13 -3
- package/src/keymap/all.ts +23 -10
- package/src/keymap/changedFilesModal.ts +23 -0
- package/src/keymap/commandPalette.ts +2 -2
- package/src/keymap/commentThreadModal.ts +1 -1
- package/src/keymap/diffView.ts +4 -2
- package/src/keymap/labelModal.ts +2 -2
- package/src/keymap/submitReviewModal.ts +48 -0
- package/src/keymap/themeModal.ts +2 -2
- package/src/pullRequestViews.ts +1 -3
- package/src/services/GitHubService.ts +20 -5
- package/src/services/MockGitHubService.ts +1 -0
- package/src/ui/CommandPalette.tsx +11 -51
- package/src/ui/PullRequestDiffPane.tsx +3 -2
- package/src/ui/PullRequestList.tsx +1 -17
- package/src/ui/colors.ts +7 -0
- package/src/ui/modals.tsx +366 -16
- package/src/ui/primitives.tsx +146 -1
package/src/ui/modals.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Data } from "effect"
|
|
2
|
-
import type { PullRequestLabel, PullRequestMergeInfo, PullRequestReviewComment } from "../domain.js"
|
|
2
|
+
import type { PullRequestLabel, PullRequestMergeInfo, PullRequestReviewComment, PullRequestReviewEvent } from "../domain.js"
|
|
3
3
|
import { availableMergeActions } from "../mergeActions.js"
|
|
4
4
|
import { clampCursor, commentEditorLines, cursorLineIndexForLines } from "./commentEditor.js"
|
|
5
5
|
import { colors, filterThemeDefinitions, themeDefinitions, type ThemeId } from "./colors.js"
|
|
6
6
|
import { commentDisplayRows, CommentSegmentsLine, type CommentDisplayLine } from "./comments.js"
|
|
7
|
-
import {
|
|
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"
|
|
8
9
|
import { labelColor, shortRepoName } from "./pullRequests.js"
|
|
9
10
|
|
|
10
11
|
export interface LabelModalState {
|
|
@@ -44,6 +45,21 @@ export interface CommentThreadModalState {
|
|
|
44
45
|
readonly scrollOffset: number
|
|
45
46
|
}
|
|
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
|
+
|
|
47
63
|
export interface ThemeModalState {
|
|
48
64
|
readonly query: string
|
|
49
65
|
readonly filterMode: boolean
|
|
@@ -66,6 +82,178 @@ export const filterLabels = (labels: readonly PullRequestLabel[], query: string)
|
|
|
66
82
|
return labels.filter((label) => label.name.toLowerCase().includes(normalized))
|
|
67
83
|
}
|
|
68
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
|
+
|
|
69
257
|
export const initialLabelModalState: LabelModalState = {
|
|
70
258
|
repository: null,
|
|
71
259
|
query: "",
|
|
@@ -103,6 +291,21 @@ export const initialCommentThreadModalState: CommentThreadModalState = {
|
|
|
103
291
|
scrollOffset: 0,
|
|
104
292
|
}
|
|
105
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
|
+
|
|
106
309
|
export const initialThemeModalState: ThemeModalState = {
|
|
107
310
|
query: "",
|
|
108
311
|
filterMode: false,
|
|
@@ -126,6 +329,8 @@ export type Modal = Data.TaggedEnum<{
|
|
|
126
329
|
Merge: MergeModalState
|
|
127
330
|
Comment: CommentModalState
|
|
128
331
|
CommentThread: CommentThreadModalState
|
|
332
|
+
ChangedFiles: ChangedFilesModalState
|
|
333
|
+
SubmitReview: SubmitReviewModalState
|
|
129
334
|
Theme: ThemeModalState
|
|
130
335
|
CommandPalette: CommandPaletteState
|
|
131
336
|
OpenRepository: OpenRepositoryModalState
|
|
@@ -143,6 +348,8 @@ export const modalInitialStates = {
|
|
|
143
348
|
Merge: initialMergeModalState,
|
|
144
349
|
Comment: initialCommentModalState,
|
|
145
350
|
CommentThread: initialCommentThreadModalState,
|
|
351
|
+
ChangedFiles: initialChangedFilesModalState,
|
|
352
|
+
SubmitReview: initialSubmitReviewModalState,
|
|
146
353
|
Theme: initialThemeModalState,
|
|
147
354
|
CommandPalette: initialCommandPaletteState,
|
|
148
355
|
OpenRepository: initialOpenRepositoryModalState,
|
|
@@ -215,7 +422,7 @@ export const LabelModal = ({
|
|
|
215
422
|
offsetTop: number
|
|
216
423
|
loadingIndicator: string
|
|
217
424
|
}) => {
|
|
218
|
-
const {
|
|
425
|
+
const { bodyHeight: maxVisible, rowWidth } = searchModalDims(modalWidth, modalHeight)
|
|
219
426
|
const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
|
|
220
427
|
const filtered = filterLabels(state.availableLabels, state.query)
|
|
221
428
|
const labelMessageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
|
|
@@ -228,24 +435,17 @@ export const LabelModal = ({
|
|
|
228
435
|
const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
|
|
229
436
|
const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
|
|
230
437
|
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
438
|
|
|
235
439
|
return (
|
|
236
|
-
<
|
|
440
|
+
<SearchModalFrame
|
|
237
441
|
left={offsetLeft}
|
|
238
442
|
top={offsetTop}
|
|
239
443
|
width={modalWidth}
|
|
240
444
|
height={modalHeight}
|
|
241
445
|
title={title}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
}
|
|
446
|
+
query={state.query}
|
|
447
|
+
placeholder="filter labels"
|
|
448
|
+
countText={countText}
|
|
249
449
|
footer={
|
|
250
450
|
<TextLine>
|
|
251
451
|
<span fg={colors.count}>↑↓</span>
|
|
@@ -287,7 +487,72 @@ export const LabelModal = ({
|
|
|
287
487
|
)
|
|
288
488
|
})
|
|
289
489
|
)}
|
|
290
|
-
</
|
|
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>
|
|
291
556
|
)
|
|
292
557
|
}
|
|
293
558
|
|
|
@@ -484,6 +749,91 @@ export const CommentModal = ({
|
|
|
484
749
|
)
|
|
485
750
|
}
|
|
486
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
|
+
|
|
487
837
|
const commentThreadRows = (comments: readonly PullRequestReviewComment[], width: number): readonly CommentDisplayLine[] =>
|
|
488
838
|
comments.flatMap((comment) => commentDisplayRows({ item: comment, width }))
|
|
489
839
|
|
|
@@ -522,7 +872,7 @@ export const CommentThreadModal = ({
|
|
|
522
872
|
headerRight={{ text: countText }}
|
|
523
873
|
subtitle={<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />}
|
|
524
874
|
bodyPadding={1}
|
|
525
|
-
footer={<HintRow items={[{ key: "↑↓", label: "scroll" }, { key: "
|
|
875
|
+
footer={<HintRow items={[{ key: "↑↓", label: "scroll" }, { key: "enter", label: "comment" }, { key: "esc", label: "close" }]} />}
|
|
526
876
|
>
|
|
527
877
|
{visibleRows.length === 0 ? (
|
|
528
878
|
<PlainLine text={fitCell("No comments on this line.", contentWidth)} fg={colors.muted} />
|
package/src/ui/primitives.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TextAttributes } from "@opentui/core"
|
|
1
|
+
import { TextAttributes, type MouseEvent } from "@opentui/core"
|
|
2
2
|
import type React from "react"
|
|
3
3
|
import { colors } from "./colors.js"
|
|
4
4
|
|
|
@@ -43,6 +43,42 @@ export const TextLine = ({ children, fg = colors.text, bg, width }: { children:
|
|
|
43
43
|
</box>
|
|
44
44
|
)
|
|
45
45
|
|
|
46
|
+
export const MatchedCell = ({ text, width, query, align = "left", matchIndexes }: { text: string; width: number; query: string; align?: "left" | "right"; matchIndexes?: readonly number[] }) => {
|
|
47
|
+
const fitted = fitCell(text, width, align)
|
|
48
|
+
if (matchIndexes && matchIndexes.length > 0) {
|
|
49
|
+
const highlighted = new Set(matchIndexes.filter((index) => index >= 0 && index < fitted.length))
|
|
50
|
+
if (highlighted.size > 0) {
|
|
51
|
+
const segments: Array<{ text: string; highlight: boolean }> = []
|
|
52
|
+
for (let index = 0; index < fitted.length; index++) {
|
|
53
|
+
const char = fitted[index]!
|
|
54
|
+
const highlight = highlighted.has(index)
|
|
55
|
+
const previous = segments[segments.length - 1]
|
|
56
|
+
if (previous && previous.highlight === highlight) previous.text += char
|
|
57
|
+
else segments.push({ text: char, highlight })
|
|
58
|
+
}
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
{segments.map((segment, index) => segment.highlight
|
|
62
|
+
? <span key={index} fg={colors.accent} attributes={TextAttributes.BOLD}>{segment.text}</span>
|
|
63
|
+
: <span key={index}>{segment.text}</span>)}
|
|
64
|
+
</>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const needle = query.trim().toLowerCase()
|
|
69
|
+
const index = needle.length > 0 ? fitted.toLowerCase().indexOf(needle) : -1
|
|
70
|
+
if (index < 0) return <span>{fitted}</span>
|
|
71
|
+
|
|
72
|
+
const end = Math.min(fitted.length, index + needle.length)
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
{index > 0 ? <span>{fitted.slice(0, index)}</span> : null}
|
|
76
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{fitted.slice(index, end)}</span>
|
|
77
|
+
{end < fitted.length ? <span>{fitted.slice(end)}</span> : null}
|
|
78
|
+
</>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
46
82
|
export const SectionTitle = ({ title }: { title: string }) => (
|
|
47
83
|
<TextLine>
|
|
48
84
|
<span fg={colors.accent} attributes={TextAttributes.BOLD}>
|
|
@@ -160,6 +196,115 @@ export const StandardModal = ({
|
|
|
160
196
|
)
|
|
161
197
|
}
|
|
162
198
|
|
|
199
|
+
export type SearchModalDims = StandardModalDims
|
|
200
|
+
|
|
201
|
+
export const searchModalDims = (modalWidth: number, modalHeight: number): SearchModalDims => {
|
|
202
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
203
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
204
|
+
const bodyHeight = Math.max(1, modalHeight - 6)
|
|
205
|
+
return { innerWidth, contentWidth, bodyHeight, rowWidth: innerWidth }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const searchModalTitleText = (title: string, contentWidth: number, countText: string) => {
|
|
209
|
+
const reserved = 1 + 1 + 1 + 8 + (countText.length > 0 ? countText.length + 2 : 0)
|
|
210
|
+
return trimCell(title, Math.max(6, Math.min(title.length, contentWidth - reserved)))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const SearchModalHeader = ({
|
|
214
|
+
title,
|
|
215
|
+
query,
|
|
216
|
+
placeholder,
|
|
217
|
+
countText = "",
|
|
218
|
+
contentWidth,
|
|
219
|
+
}: {
|
|
220
|
+
title: string
|
|
221
|
+
query: string
|
|
222
|
+
placeholder: string
|
|
223
|
+
countText?: string
|
|
224
|
+
contentWidth: number
|
|
225
|
+
}) => {
|
|
226
|
+
const titleText = searchModalTitleText(title, contentWidth, countText)
|
|
227
|
+
const headerGap = 1
|
|
228
|
+
const headerDivider = "│"
|
|
229
|
+
const searchGap = 1
|
|
230
|
+
const searchStart = titleText.length + headerGap + headerDivider.length + searchGap
|
|
231
|
+
const countGap = countText.length > 0 ? 2 : 0
|
|
232
|
+
const searchWidth = Math.max(1, contentWidth - searchStart - countGap - countText.length)
|
|
233
|
+
const queryText = trimCell(query, Math.max(0, searchWidth - 1))
|
|
234
|
+
const queryPadding = Math.max(0, searchWidth - queryText.length - 1)
|
|
235
|
+
const caretFg = colors.background === "transparent" ? colors.text : colors.background
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<TextLine>
|
|
239
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{titleText}</span>
|
|
240
|
+
<span>{" ".repeat(headerGap)}</span>
|
|
241
|
+
<span fg={colors.separator}>{headerDivider}</span>
|
|
242
|
+
<span>{" ".repeat(searchGap)}</span>
|
|
243
|
+
{query.length > 0 ? (
|
|
244
|
+
<>
|
|
245
|
+
<span fg={colors.text}>{queryText}</span>
|
|
246
|
+
<span bg={colors.muted} fg={caretFg}> </span>
|
|
247
|
+
{queryPadding > 0 ? <span>{" ".repeat(queryPadding)}</span> : null}
|
|
248
|
+
</>
|
|
249
|
+
) : (
|
|
250
|
+
<>
|
|
251
|
+
<span bg={colors.muted} fg={caretFg}>{placeholder[0] ?? " "}</span>
|
|
252
|
+
<span fg={colors.muted}>{fitCell(placeholder.slice(1), Math.max(0, searchWidth - 1))}</span>
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
{countText.length > 0 && searchWidth > placeholder.length ? (
|
|
256
|
+
<>
|
|
257
|
+
<span>{" ".repeat(countGap)}</span>
|
|
258
|
+
<span fg={colors.muted}>{countText}</span>
|
|
259
|
+
</>
|
|
260
|
+
) : null}
|
|
261
|
+
</TextLine>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export const SearchModalFrame = ({
|
|
266
|
+
left,
|
|
267
|
+
top,
|
|
268
|
+
width,
|
|
269
|
+
height,
|
|
270
|
+
title,
|
|
271
|
+
query,
|
|
272
|
+
placeholder,
|
|
273
|
+
countText = "",
|
|
274
|
+
footer,
|
|
275
|
+
bodyPadding = 0,
|
|
276
|
+
onBodyMouseScroll,
|
|
277
|
+
children,
|
|
278
|
+
}: {
|
|
279
|
+
left: number
|
|
280
|
+
top: number
|
|
281
|
+
width: number
|
|
282
|
+
height: number
|
|
283
|
+
title: string
|
|
284
|
+
query: string
|
|
285
|
+
placeholder: string
|
|
286
|
+
countText?: string
|
|
287
|
+
footer: React.ReactNode
|
|
288
|
+
bodyPadding?: number
|
|
289
|
+
onBodyMouseScroll?: (event: MouseEvent) => void
|
|
290
|
+
children: React.ReactNode
|
|
291
|
+
}) => {
|
|
292
|
+
const { innerWidth, contentWidth, bodyHeight } = searchModalDims(width, height)
|
|
293
|
+
const titleText = searchModalTitleText(title, contentWidth, countText)
|
|
294
|
+
const dividerColumn = 1 + titleText.length + 1
|
|
295
|
+
return (
|
|
296
|
+
<ModalFrame left={left} top={top} width={width} height={height} junctionRows={[1, height - 4]} topJunctionColumns={[dividerColumn]}>
|
|
297
|
+
<PaddedRow>
|
|
298
|
+
<SearchModalHeader title={title} query={query} placeholder={placeholder} countText={countText} contentWidth={contentWidth} />
|
|
299
|
+
</PaddedRow>
|
|
300
|
+
<Divider width={innerWidth} junctionAt={dividerColumn} junctionChar="┴" />
|
|
301
|
+
<box height={bodyHeight} flexDirection="column" paddingLeft={bodyPadding} paddingRight={bodyPadding} {...(onBodyMouseScroll ? { onMouseScroll: onBodyMouseScroll } : {})}>{children}</box>
|
|
302
|
+
<Divider width={innerWidth} />
|
|
303
|
+
<PaddedRow>{footer}</PaddedRow>
|
|
304
|
+
</ModalFrame>
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
163
308
|
export const ModalFrame = ({
|
|
164
309
|
children,
|
|
165
310
|
left,
|