@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/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 { centerCell, Filler, fitCell, HintRow, PlainLine, StandardModal, standardModalDims, TextLine } from "./primitives.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"
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 { contentWidth, bodyHeight: maxVisible, rowWidth } = standardModalDims(modalWidth, modalHeight)
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
- <StandardModal
440
+ <SearchModalFrame
237
441
  left={offsetLeft}
238
442
  top={offsetTop}
239
443
  width={modalWidth}
240
444
  height={modalHeight}
241
445
  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
- }
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
- </StandardModal>
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: "a", label: "comment" }, { key: "esc", label: "close" }]} />}
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} />
@@ -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,