@kitlangton/ghui 0.1.19 → 0.1.21
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 +14 -3
- package/bin/ghui.js +64 -1
- package/package.json +18 -7
- package/src/App.tsx +1075 -653
- package/src/appCommands.ts +330 -0
- package/src/commands.ts +73 -0
- package/src/config.ts +10 -0
- package/src/domain.ts +29 -20
- package/src/errors.ts +10 -0
- package/src/index.tsx +30 -3
- package/src/mergeActions.ts +1 -6
- package/src/pullRequestCache.ts +19 -0
- package/src/pullRequestViews.ts +45 -0
- package/src/services/BrowserOpener.ts +22 -0
- package/src/services/Clipboard.ts +46 -0
- package/src/services/CommandRunner.ts +14 -6
- package/src/services/GitHubService.ts +290 -126
- package/src/services/MockGitHubService.ts +146 -0
- package/src/ui/CommandPalette.tsx +156 -0
- package/src/ui/DetailsPane.tsx +49 -63
- package/src/ui/FooterHints.tsx +84 -179
- package/src/ui/PullRequestDiffPane.tsx +29 -50
- package/src/ui/PullRequestList.tsx +99 -45
- package/src/ui/colors.ts +167 -1
- package/src/ui/diff.ts +34 -44
- package/src/ui/diffStats.tsx +25 -0
- package/src/ui/modals.tsx +263 -295
- package/src/ui/primitives.tsx +90 -0
- package/src/ui/pullRequests.ts +44 -12
- package/src/ui/singleLineInput.ts +25 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import type { AppCommand } from "./commands.js"
|
|
2
|
+
import { defineCommand } from "./commands.js"
|
|
3
|
+
import type { LoadStatus, PullRequestItem } from "./domain.js"
|
|
4
|
+
import type { DiffView, DiffWrapMode } from "./ui/diff.js"
|
|
5
|
+
import { type PullRequestView, viewEquals, viewLabel, viewMode } from "./pullRequestViews.js"
|
|
6
|
+
|
|
7
|
+
interface AppCommandActions {
|
|
8
|
+
readonly openCommandPalette: () => void
|
|
9
|
+
readonly refreshPullRequests: (message?: string) => void
|
|
10
|
+
readonly openFilter: () => void
|
|
11
|
+
readonly clearFilter: () => void
|
|
12
|
+
readonly openThemeModal: () => void
|
|
13
|
+
readonly openRepositoryPicker: () => void
|
|
14
|
+
readonly loadMorePullRequests: () => void
|
|
15
|
+
readonly switchViewTo: (view: PullRequestView) => void
|
|
16
|
+
readonly openDetails: () => void
|
|
17
|
+
readonly closeDetails: () => void
|
|
18
|
+
readonly openDiffView: () => void
|
|
19
|
+
readonly closeDiffView: () => void
|
|
20
|
+
readonly reloadDiff: () => void
|
|
21
|
+
readonly toggleDiffRenderView: () => void
|
|
22
|
+
readonly toggleDiffWrapMode: () => void
|
|
23
|
+
readonly jumpDiffFile: (delta: 1 | -1) => void
|
|
24
|
+
readonly toggleDiffCommentMode: () => void
|
|
25
|
+
readonly openDiffCommentModal: () => void
|
|
26
|
+
readonly togglePullRequestDraftStatus: () => void
|
|
27
|
+
readonly openLabelModal: () => void
|
|
28
|
+
readonly openMergeModal: () => void
|
|
29
|
+
readonly openCloseModal: () => void
|
|
30
|
+
readonly openPullRequestInBrowser: () => void
|
|
31
|
+
readonly copyPullRequestMetadata: () => void
|
|
32
|
+
readonly quit: () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface BuildAppCommandsInput {
|
|
36
|
+
readonly pullRequestStatus: LoadStatus
|
|
37
|
+
readonly filterQuery: string
|
|
38
|
+
readonly filterMode: boolean
|
|
39
|
+
readonly selectedRepository: string | null
|
|
40
|
+
readonly activeViews: readonly PullRequestView[]
|
|
41
|
+
readonly activeView: PullRequestView
|
|
42
|
+
readonly loadedPullRequestCount: number
|
|
43
|
+
readonly hasMorePullRequests: boolean
|
|
44
|
+
readonly isLoadingMorePullRequests: boolean
|
|
45
|
+
readonly selectedPullRequest: PullRequestItem | null
|
|
46
|
+
readonly detailFullView: boolean
|
|
47
|
+
readonly diffFullView: boolean
|
|
48
|
+
readonly diffReady: boolean
|
|
49
|
+
readonly effectiveDiffRenderView: DiffView
|
|
50
|
+
readonly diffWrapMode: DiffWrapMode
|
|
51
|
+
readonly readyDiffFileCount: number
|
|
52
|
+
readonly diffFileIndex: number
|
|
53
|
+
readonly diffCommentMode: boolean
|
|
54
|
+
readonly selectedDiffCommentAnchorLabel: string | null
|
|
55
|
+
readonly actions: AppCommandActions
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const buildAppCommands = ({
|
|
59
|
+
pullRequestStatus,
|
|
60
|
+
filterQuery,
|
|
61
|
+
filterMode,
|
|
62
|
+
selectedRepository,
|
|
63
|
+
activeViews,
|
|
64
|
+
activeView,
|
|
65
|
+
loadedPullRequestCount,
|
|
66
|
+
hasMorePullRequests,
|
|
67
|
+
isLoadingMorePullRequests,
|
|
68
|
+
selectedPullRequest,
|
|
69
|
+
detailFullView,
|
|
70
|
+
diffFullView,
|
|
71
|
+
diffReady,
|
|
72
|
+
effectiveDiffRenderView,
|
|
73
|
+
diffWrapMode,
|
|
74
|
+
readyDiffFileCount,
|
|
75
|
+
diffFileIndex,
|
|
76
|
+
diffCommentMode,
|
|
77
|
+
selectedDiffCommentAnchorLabel,
|
|
78
|
+
actions,
|
|
79
|
+
}: BuildAppCommandsInput): readonly AppCommand[] => {
|
|
80
|
+
const selectedPullRequestLabel = selectedPullRequest ? `#${selectedPullRequest.number} ${selectedPullRequest.repository}` : "No pull request selected"
|
|
81
|
+
const noPullRequestReason = selectedPullRequest ? null : "Select a pull request first."
|
|
82
|
+
const noOpenPullRequestReason = selectedPullRequest?.state === "open" ? null : selectedPullRequest ? "Pull request is not open." : noPullRequestReason
|
|
83
|
+
const diffReadyReason = selectedPullRequest
|
|
84
|
+
? diffReady ? null : "Load the diff before running this command."
|
|
85
|
+
: noPullRequestReason
|
|
86
|
+
const diffOpenReadyReason = diffFullView ? diffReadyReason : "Open a diff first."
|
|
87
|
+
const loadMoreDisabledReason = isLoadingMorePullRequests
|
|
88
|
+
? "Already loading more pull requests."
|
|
89
|
+
: hasMorePullRequests ? null : "No more pull requests loaded by this view."
|
|
90
|
+
|
|
91
|
+
const forSelected = (
|
|
92
|
+
command: Omit<AppCommand, "subtitle" | "disabledReason"> & { readonly requireOpen?: boolean },
|
|
93
|
+
): AppCommand => {
|
|
94
|
+
const { requireOpen, ...rest } = command
|
|
95
|
+
return defineCommand({
|
|
96
|
+
...rest,
|
|
97
|
+
subtitle: selectedPullRequestLabel,
|
|
98
|
+
disabledReason: requireOpen ? noOpenPullRequestReason : noPullRequestReason,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return [
|
|
103
|
+
defineCommand({
|
|
104
|
+
id: "command.open",
|
|
105
|
+
title: "Open command palette",
|
|
106
|
+
scope: "Global",
|
|
107
|
+
subtitle: "Search every available route through ghui",
|
|
108
|
+
shortcut: "ctrl-p/cmd-k",
|
|
109
|
+
keywords: ["palette", "commands", "deck"],
|
|
110
|
+
run: actions.openCommandPalette,
|
|
111
|
+
}),
|
|
112
|
+
defineCommand({
|
|
113
|
+
id: "pull.refresh",
|
|
114
|
+
title: pullRequestStatus === "error" ? "Retry loading pull requests" : "Refresh pull requests",
|
|
115
|
+
scope: "Global",
|
|
116
|
+
subtitle: "Fetch the latest queue from GitHub",
|
|
117
|
+
shortcut: "r",
|
|
118
|
+
keywords: ["reload", "sync"],
|
|
119
|
+
run: () => actions.refreshPullRequests("Refreshed"),
|
|
120
|
+
}),
|
|
121
|
+
defineCommand({
|
|
122
|
+
id: "filter.open",
|
|
123
|
+
title: "Filter pull requests",
|
|
124
|
+
scope: "Global",
|
|
125
|
+
subtitle: "Search the visible queue",
|
|
126
|
+
shortcut: "/",
|
|
127
|
+
keywords: ["search"],
|
|
128
|
+
run: actions.openFilter,
|
|
129
|
+
}),
|
|
130
|
+
defineCommand({
|
|
131
|
+
id: "filter.clear",
|
|
132
|
+
title: "Clear pull request filter",
|
|
133
|
+
scope: "Global",
|
|
134
|
+
subtitle: "Show every pull request in the current queue",
|
|
135
|
+
shortcut: "esc",
|
|
136
|
+
disabledReason: filterQuery.length > 0 || filterMode ? null : "No filter is active.",
|
|
137
|
+
run: actions.clearFilter,
|
|
138
|
+
}),
|
|
139
|
+
defineCommand({
|
|
140
|
+
id: "theme.open",
|
|
141
|
+
title: "Choose theme",
|
|
142
|
+
scope: "Global",
|
|
143
|
+
subtitle: "Preview and persist a terminal color theme",
|
|
144
|
+
shortcut: "t",
|
|
145
|
+
keywords: ["colors", "appearance"],
|
|
146
|
+
run: actions.openThemeModal,
|
|
147
|
+
}),
|
|
148
|
+
defineCommand({
|
|
149
|
+
id: "repository.open",
|
|
150
|
+
title: "Open repository...",
|
|
151
|
+
scope: "View",
|
|
152
|
+
subtitle: selectedRepository ? `Current repository: ${selectedRepository}` : "Enter owner/name or a GitHub URL",
|
|
153
|
+
keywords: ["repo", "repository", "owner", "github"],
|
|
154
|
+
run: actions.openRepositoryPicker,
|
|
155
|
+
}),
|
|
156
|
+
...activeViews.map((view) => defineCommand({
|
|
157
|
+
id: view._tag === "Repository" ? "view.repository" : `view.${view.mode}`,
|
|
158
|
+
title: `Show ${viewLabel(view)} view`,
|
|
159
|
+
scope: "View" as const,
|
|
160
|
+
subtitle: viewEquals(view, activeView) ? "Already showing this view" : "Switch pull request view",
|
|
161
|
+
keywords: [viewMode(view), viewLabel(view), "queue", "view"],
|
|
162
|
+
disabledReason: viewEquals(view, activeView) ? "Already showing this view." : null,
|
|
163
|
+
run: () => actions.switchViewTo(view),
|
|
164
|
+
})),
|
|
165
|
+
defineCommand({
|
|
166
|
+
id: "pull.load-more",
|
|
167
|
+
title: "Load more pull requests",
|
|
168
|
+
scope: "Navigation",
|
|
169
|
+
subtitle: `${loadedPullRequestCount} loaded`,
|
|
170
|
+
disabledReason: loadMoreDisabledReason,
|
|
171
|
+
keywords: ["next page", "pagination", "more"],
|
|
172
|
+
run: actions.loadMorePullRequests,
|
|
173
|
+
}),
|
|
174
|
+
forSelected({
|
|
175
|
+
id: "detail.open",
|
|
176
|
+
title: "Open pull request details",
|
|
177
|
+
scope: "Pull request",
|
|
178
|
+
shortcut: "enter",
|
|
179
|
+
run: actions.openDetails,
|
|
180
|
+
}),
|
|
181
|
+
defineCommand({
|
|
182
|
+
id: "detail.close",
|
|
183
|
+
title: "Close details view",
|
|
184
|
+
scope: "Pull request",
|
|
185
|
+
subtitle: "Return to the queue",
|
|
186
|
+
shortcut: "esc",
|
|
187
|
+
disabledReason: detailFullView ? null : "Details view is not open.",
|
|
188
|
+
run: actions.closeDetails,
|
|
189
|
+
}),
|
|
190
|
+
forSelected({
|
|
191
|
+
id: "diff.open",
|
|
192
|
+
title: "Open stacked diff",
|
|
193
|
+
scope: "Diff",
|
|
194
|
+
shortcut: "d",
|
|
195
|
+
keywords: ["files", "patch"],
|
|
196
|
+
run: actions.openDiffView,
|
|
197
|
+
}),
|
|
198
|
+
defineCommand({
|
|
199
|
+
id: "diff.close",
|
|
200
|
+
title: "Close diff view",
|
|
201
|
+
scope: "Diff",
|
|
202
|
+
subtitle: "Return to the queue or detail view",
|
|
203
|
+
shortcut: "esc",
|
|
204
|
+
disabledReason: diffFullView ? null : "Diff view is not open.",
|
|
205
|
+
run: actions.closeDiffView,
|
|
206
|
+
}),
|
|
207
|
+
defineCommand({
|
|
208
|
+
id: "diff.reload",
|
|
209
|
+
title: "Reload diff",
|
|
210
|
+
scope: "Diff",
|
|
211
|
+
subtitle: selectedPullRequestLabel,
|
|
212
|
+
shortcut: "r",
|
|
213
|
+
disabledReason: diffFullView && selectedPullRequest ? null : "Open a pull request diff first.",
|
|
214
|
+
keywords: ["refresh", "comments"],
|
|
215
|
+
run: actions.reloadDiff,
|
|
216
|
+
}),
|
|
217
|
+
defineCommand({
|
|
218
|
+
id: "diff.toggle-view",
|
|
219
|
+
title: "Toggle diff split/unified view",
|
|
220
|
+
scope: "Diff",
|
|
221
|
+
subtitle: effectiveDiffRenderView === "split" ? "Switch to unified view" : "Switch to split view",
|
|
222
|
+
shortcut: "v",
|
|
223
|
+
disabledReason: diffFullView ? null : "Open a diff first.",
|
|
224
|
+
run: actions.toggleDiffRenderView,
|
|
225
|
+
}),
|
|
226
|
+
defineCommand({
|
|
227
|
+
id: "diff.toggle-wrap",
|
|
228
|
+
title: "Toggle diff word wrap",
|
|
229
|
+
scope: "Diff",
|
|
230
|
+
subtitle: diffWrapMode === "none" ? "Wrap long diff lines" : "Keep diff lines unwrapped",
|
|
231
|
+
shortcut: "w",
|
|
232
|
+
disabledReason: diffFullView ? null : "Open a diff first.",
|
|
233
|
+
run: actions.toggleDiffWrapMode,
|
|
234
|
+
}),
|
|
235
|
+
defineCommand({
|
|
236
|
+
id: "diff.next-file",
|
|
237
|
+
title: "Next diff file",
|
|
238
|
+
scope: "Diff",
|
|
239
|
+
subtitle: readyDiffFileCount > 0 ? `${diffFileIndex + 1}/${readyDiffFileCount}` : "No diff files loaded",
|
|
240
|
+
shortcut: "]",
|
|
241
|
+
disabledReason: diffFullView && readyDiffFileCount > 0 ? null : diffOpenReadyReason,
|
|
242
|
+
run: () => actions.jumpDiffFile(1),
|
|
243
|
+
}),
|
|
244
|
+
defineCommand({
|
|
245
|
+
id: "diff.previous-file",
|
|
246
|
+
title: "Previous diff file",
|
|
247
|
+
scope: "Diff",
|
|
248
|
+
subtitle: readyDiffFileCount > 0 ? `${diffFileIndex + 1}/${readyDiffFileCount}` : "No diff files loaded",
|
|
249
|
+
shortcut: "[",
|
|
250
|
+
disabledReason: diffFullView && readyDiffFileCount > 0 ? null : diffOpenReadyReason,
|
|
251
|
+
run: () => actions.jumpDiffFile(-1),
|
|
252
|
+
}),
|
|
253
|
+
defineCommand({
|
|
254
|
+
id: "diff.comment-mode",
|
|
255
|
+
title: diffCommentMode ? "Exit diff comment mode" : "Enter diff comment mode",
|
|
256
|
+
scope: "Diff",
|
|
257
|
+
subtitle: diffCommentMode ? "Return to diff scrolling" : "Choose a line to comment on",
|
|
258
|
+
shortcut: "c",
|
|
259
|
+
disabledReason: diffFullView && diffReady ? null : diffOpenReadyReason,
|
|
260
|
+
keywords: ["review", "comment", "line"],
|
|
261
|
+
run: actions.toggleDiffCommentMode,
|
|
262
|
+
}),
|
|
263
|
+
defineCommand({
|
|
264
|
+
id: "diff.add-comment",
|
|
265
|
+
title: "Add comment on selected diff line",
|
|
266
|
+
scope: "Diff",
|
|
267
|
+
subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
|
|
268
|
+
shortcut: "a",
|
|
269
|
+
disabledReason: diffCommentMode && selectedDiffCommentAnchorLabel ? null : "Enter diff comment mode and select a line first.",
|
|
270
|
+
keywords: ["review", "reply"],
|
|
271
|
+
run: actions.openDiffCommentModal,
|
|
272
|
+
}),
|
|
273
|
+
forSelected({
|
|
274
|
+
id: "pull.toggle-draft",
|
|
275
|
+
title: selectedPullRequest?.reviewStatus === "draft" ? "Mark ready for review" : "Mark as draft",
|
|
276
|
+
scope: "Pull request",
|
|
277
|
+
shortcut: "s",
|
|
278
|
+
keywords: ["state", "ready"],
|
|
279
|
+
run: actions.togglePullRequestDraftStatus,
|
|
280
|
+
}),
|
|
281
|
+
forSelected({
|
|
282
|
+
id: "pull.labels",
|
|
283
|
+
title: "Manage labels",
|
|
284
|
+
scope: "Pull request",
|
|
285
|
+
shortcut: "l",
|
|
286
|
+
run: actions.openLabelModal,
|
|
287
|
+
}),
|
|
288
|
+
forSelected({
|
|
289
|
+
id: "pull.merge",
|
|
290
|
+
title: "Merge pull request",
|
|
291
|
+
scope: "Pull request",
|
|
292
|
+
shortcut: "m",
|
|
293
|
+
keywords: ["auto merge", "squash"],
|
|
294
|
+
run: actions.openMergeModal,
|
|
295
|
+
}),
|
|
296
|
+
forSelected({
|
|
297
|
+
id: "pull.close",
|
|
298
|
+
title: "Close pull request",
|
|
299
|
+
scope: "Pull request",
|
|
300
|
+
shortcut: "x",
|
|
301
|
+
requireOpen: true,
|
|
302
|
+
run: actions.openCloseModal,
|
|
303
|
+
}),
|
|
304
|
+
forSelected({
|
|
305
|
+
id: "pull.open-browser",
|
|
306
|
+
title: "Open pull request in browser",
|
|
307
|
+
scope: "Pull request",
|
|
308
|
+
shortcut: "o",
|
|
309
|
+
keywords: ["github", "web"],
|
|
310
|
+
run: actions.openPullRequestInBrowser,
|
|
311
|
+
}),
|
|
312
|
+
forSelected({
|
|
313
|
+
id: "pull.copy-metadata",
|
|
314
|
+
title: "Copy pull request metadata",
|
|
315
|
+
scope: "Pull request",
|
|
316
|
+
shortcut: "y",
|
|
317
|
+
keywords: ["clipboard", "url", "title"],
|
|
318
|
+
run: actions.copyPullRequestMetadata,
|
|
319
|
+
}),
|
|
320
|
+
defineCommand({
|
|
321
|
+
id: "app.quit",
|
|
322
|
+
title: "Quit ghui",
|
|
323
|
+
scope: "System",
|
|
324
|
+
subtitle: "Leave the terminal UI",
|
|
325
|
+
shortcut: "q",
|
|
326
|
+
keywords: ["exit"],
|
|
327
|
+
run: actions.quit,
|
|
328
|
+
}),
|
|
329
|
+
]
|
|
330
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type CommandScope = "Global" | "View" | "Pull request" | "Diff" | "Navigation" | "System"
|
|
2
|
+
|
|
3
|
+
const SCOPE_ORDER: readonly CommandScope[] = ["Global", "View", "Pull request", "Diff", "Navigation", "System"]
|
|
4
|
+
|
|
5
|
+
export const sortCommandsByScope = (commands: readonly AppCommand[]) =>
|
|
6
|
+
[...commands].sort((left, right) => SCOPE_ORDER.indexOf(left.scope) - SCOPE_ORDER.indexOf(right.scope))
|
|
7
|
+
|
|
8
|
+
export interface AppCommand {
|
|
9
|
+
readonly id: string
|
|
10
|
+
readonly title: string
|
|
11
|
+
readonly scope: CommandScope
|
|
12
|
+
readonly run: () => void
|
|
13
|
+
readonly subtitle?: string
|
|
14
|
+
readonly shortcut?: string
|
|
15
|
+
readonly keywords?: readonly string[]
|
|
16
|
+
readonly disabledReason?: string | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const defineCommand = (command: AppCommand): AppCommand => command
|
|
20
|
+
|
|
21
|
+
export const commandEnabled = (command: AppCommand) => !command.disabledReason
|
|
22
|
+
|
|
23
|
+
const normalize = (text: string) => text.toLowerCase().replace(/[^a-z0-9#]+/g, " ").trim()
|
|
24
|
+
|
|
25
|
+
const acronym = (text: string) => normalize(text).split(" ").filter(Boolean).map((word) => word[0]).join("")
|
|
26
|
+
|
|
27
|
+
const fuzzyIncludes = (text: string, query: string) => {
|
|
28
|
+
let index = 0
|
|
29
|
+
for (const char of text) {
|
|
30
|
+
if (char === query[index]) index++
|
|
31
|
+
if (index >= query.length) return true
|
|
32
|
+
}
|
|
33
|
+
return query.length === 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const commandSearchText = (command: AppCommand) => normalize([
|
|
37
|
+
command.title,
|
|
38
|
+
command.subtitle,
|
|
39
|
+
command.scope,
|
|
40
|
+
command.shortcut,
|
|
41
|
+
...(command.keywords ?? []),
|
|
42
|
+
].filter(Boolean).join(" "))
|
|
43
|
+
|
|
44
|
+
const commandScore = (command: AppCommand, query: string) => {
|
|
45
|
+
const normalizedQuery = normalize(query)
|
|
46
|
+
if (normalizedQuery.length === 0) return 0
|
|
47
|
+
|
|
48
|
+
const title = normalize(command.title)
|
|
49
|
+
const searchText = commandSearchText(command)
|
|
50
|
+
const titleAcronym = acronym(command.title)
|
|
51
|
+
if (title.startsWith(normalizedQuery)) return 0
|
|
52
|
+
if (searchText.startsWith(normalizedQuery)) return 1
|
|
53
|
+
if (title.includes(normalizedQuery)) return 2
|
|
54
|
+
if (searchText.includes(normalizedQuery)) return 3
|
|
55
|
+
if (titleAcronym.startsWith(normalizedQuery)) return 4
|
|
56
|
+
if (fuzzyIncludes(searchText, normalizedQuery.replaceAll(" ", ""))) return 5
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const filterCommands = (commands: readonly AppCommand[], query: string) => {
|
|
61
|
+
return commands.flatMap((command, index) => {
|
|
62
|
+
const score = commandScore(command, query)
|
|
63
|
+
return score === null ? [] : [{ command, index, score }]
|
|
64
|
+
}).sort((left, right) => {
|
|
65
|
+
const enabled = Number(commandEnabled(right.command)) - Number(commandEnabled(left.command))
|
|
66
|
+
return enabled || left.score - right.score || left.index - right.index
|
|
67
|
+
}).map(({ command }) => command)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const clampCommandIndex = (index: number, commands: readonly AppCommand[]) => {
|
|
71
|
+
if (commands.length === 0) return 0
|
|
72
|
+
return Math.max(0, Math.min(commands.length - 1, index))
|
|
73
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { Config, Effect } from "effect"
|
|
|
2
2
|
|
|
3
3
|
const positiveIntOr = (fallback: number) => (value: number) => Number.isFinite(value) && value > 0 ? value : fallback
|
|
4
4
|
|
|
5
|
+
const pageSizeOr = (fallback: number) => (value: number) => Math.min(100, positiveIntOr(fallback)(value))
|
|
6
|
+
|
|
5
7
|
const appConfig = Config.all({
|
|
6
8
|
author: Config.string("GHUI_AUTHOR").pipe(
|
|
7
9
|
Config.withDefault("@me"),
|
|
@@ -11,6 +13,14 @@ const appConfig = Config.all({
|
|
|
11
13
|
Config.withDefault(200),
|
|
12
14
|
Config.map(positiveIntOr(200)),
|
|
13
15
|
),
|
|
16
|
+
prPageSize: Config.int("GHUI_PR_PAGE_SIZE").pipe(
|
|
17
|
+
Config.withDefault(50),
|
|
18
|
+
Config.map(pageSizeOr(50)),
|
|
19
|
+
),
|
|
20
|
+
repository: Config.string("GHUI_REPO").pipe(
|
|
21
|
+
Config.withDefault(""),
|
|
22
|
+
Config.map((value) => value.trim() || null),
|
|
23
|
+
),
|
|
14
24
|
})
|
|
15
25
|
|
|
16
26
|
export const config = Effect.runSync(Effect.gen(function*() {
|
package/src/domain.ts
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
import { Schema } from "effect"
|
|
2
2
|
|
|
3
|
-
export
|
|
4
|
-
export type LoadStatus = Schema.Schema.Type<typeof LoadStatus>
|
|
3
|
+
export type LoadStatus = "loading" | "ready" | "error"
|
|
5
4
|
|
|
6
|
-
export
|
|
7
|
-
export type PullRequestState = Schema.Schema.Type<typeof PullRequestState>
|
|
5
|
+
export type PullRequestState = "open" | "closed" | "merged"
|
|
8
6
|
|
|
9
|
-
export const
|
|
10
|
-
export type
|
|
11
|
-
export
|
|
7
|
+
export const pullRequestQueueModes = ["authored", "review", "assigned", "mentioned"] as const
|
|
8
|
+
export type PullRequestUserQueueMode = (typeof pullRequestQueueModes)[number]
|
|
9
|
+
export type PullRequestQueueMode = "repository" | PullRequestUserQueueMode
|
|
12
10
|
|
|
13
11
|
export const pullRequestQueueLabels = {
|
|
12
|
+
repository: "repository",
|
|
14
13
|
authored: "authored",
|
|
15
14
|
review: "review requested",
|
|
16
15
|
assigned: "assigned",
|
|
17
16
|
mentioned: "mentioned",
|
|
18
17
|
} as const satisfies Record<PullRequestQueueMode, string>
|
|
19
18
|
|
|
20
|
-
export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, author: string) => {
|
|
19
|
+
export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, author: string, repository: string | null) => {
|
|
21
20
|
const qualifiers = {
|
|
21
|
+
repository: repository ? `repo:${repository}` : `author:${author}`,
|
|
22
22
|
authored: `author:${author}`,
|
|
23
23
|
review: "review-requested:@me",
|
|
24
24
|
assigned: "assignee:@me",
|
|
@@ -27,26 +27,22 @@ export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, auth
|
|
|
27
27
|
return qualifiers[mode]
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export
|
|
31
|
-
export type CheckConclusion = Schema.Schema.Type<typeof CheckConclusion>
|
|
30
|
+
export type CheckConclusion = "success" | "failure" | "neutral" | "skipped" | "cancelled" | "timed_out"
|
|
32
31
|
|
|
33
|
-
export
|
|
34
|
-
export type CheckRunStatus = Schema.Schema.Type<typeof CheckRunStatus>
|
|
32
|
+
export type CheckRunStatus = "completed" | "in_progress" | "queued" | "pending"
|
|
35
33
|
|
|
36
|
-
export
|
|
37
|
-
export type CheckRollupStatus = Schema.Schema.Type<typeof CheckRollupStatus>
|
|
34
|
+
export type CheckRollupStatus = "passing" | "pending" | "failing" | "none"
|
|
38
35
|
|
|
39
|
-
export
|
|
40
|
-
export type ReviewStatus = Schema.Schema.Type<typeof ReviewStatus>
|
|
36
|
+
export type ReviewStatus = "draft" | "approved" | "changes" | "review" | "none"
|
|
41
37
|
|
|
42
|
-
export
|
|
43
|
-
export type Mergeable = Schema.Schema.Type<typeof Mergeable>
|
|
38
|
+
export type Mergeable = "mergeable" | "conflicting" | "unknown"
|
|
44
39
|
|
|
40
|
+
// DiffCommentSide is the only literal type still consumed at runtime — GitHubService
|
|
41
|
+
// uses it as a Schema inside PullRequestCommentSchema.
|
|
45
42
|
export const DiffCommentSide = Schema.Literals(["LEFT", "RIGHT"])
|
|
46
43
|
export type DiffCommentSide = Schema.Schema.Type<typeof DiffCommentSide>
|
|
47
44
|
|
|
48
|
-
export
|
|
49
|
-
export type PullRequestMergeAction = Schema.Schema.Type<typeof PullRequestMergeAction>
|
|
45
|
+
export type PullRequestMergeAction = "squash" | "auto" | "admin" | "disable-auto"
|
|
50
46
|
|
|
51
47
|
export interface CheckItem {
|
|
52
48
|
readonly name: string
|
|
@@ -103,6 +99,19 @@ export interface PullRequestItem {
|
|
|
103
99
|
readonly url: string
|
|
104
100
|
}
|
|
105
101
|
|
|
102
|
+
export interface PullRequestPage {
|
|
103
|
+
readonly items: readonly PullRequestItem[]
|
|
104
|
+
readonly endCursor: string | null
|
|
105
|
+
readonly hasNextPage: boolean
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ListPullRequestPageInput {
|
|
109
|
+
readonly mode: PullRequestQueueMode
|
|
110
|
+
readonly repository: string | null
|
|
111
|
+
readonly cursor: string | null
|
|
112
|
+
readonly pageSize: number
|
|
113
|
+
}
|
|
114
|
+
|
|
106
115
|
export interface PullRequestMergeInfo {
|
|
107
116
|
readonly repository: string
|
|
108
117
|
readonly number: number
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const hasStringField = <K extends string>(value: object, key: K): value is { [P in K]: string } & object =>
|
|
2
|
+
key in value && typeof (value as Record<string, unknown>)[key] === "string"
|
|
3
|
+
|
|
4
|
+
export const errorMessage = (error: unknown): string => {
|
|
5
|
+
if (typeof error === "object" && error !== null) {
|
|
6
|
+
if (hasStringField(error, "detail") && error.detail.length > 0) return error.detail
|
|
7
|
+
if (hasStringField(error, "message") && error.message.length > 0) return error.message
|
|
8
|
+
}
|
|
9
|
+
return error instanceof Error ? error.message : String(error)
|
|
10
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { createCliRenderer } from "@opentui/core"
|
|
3
|
+
import { addDefaultParsers, createCliRenderer, createTerminalPalette } from "@opentui/core"
|
|
4
4
|
import { RegistryProvider } from "@effect/atom-react"
|
|
5
5
|
import { createRoot } from "@opentui/react"
|
|
6
|
-
import {
|
|
6
|
+
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
|
7
|
+
import { KeymapProvider } from "@opentui/keymap/react"
|
|
7
8
|
|
|
8
9
|
process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
|
|
9
10
|
|
|
11
|
+
addDefaultParsers([
|
|
12
|
+
{
|
|
13
|
+
filetype: "bash",
|
|
14
|
+
aliases: ["sh", "shell", "zsh", "ksh"],
|
|
15
|
+
wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.25.1/tree-sitter-bash.wasm",
|
|
16
|
+
queries: {
|
|
17
|
+
highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-bash/v0.25.1/queries/highlights.scm"],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
])
|
|
21
|
+
|
|
10
22
|
const FOCUS_REPORTING_ENABLE = "\x1b[?1004h"
|
|
11
23
|
const FOCUS_REPORTING_DISABLE = "\x1b[?1004l"
|
|
12
24
|
|
|
25
|
+
const paletteDetector = createTerminalPalette(process.stdin, process.stdout)
|
|
26
|
+
const [terminalColors, { setSystemThemeColors }, { App }] = await Promise.all([
|
|
27
|
+
paletteDetector.detect({ timeout: 150 }).catch(() => null).finally(() => paletteDetector.cleanup()),
|
|
28
|
+
import("./ui/colors.js"),
|
|
29
|
+
import("./App.js"),
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
if (terminalColors) {
|
|
33
|
+
setSystemThemeColors(terminalColors)
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
const renderer = await createCliRenderer({
|
|
14
37
|
exitOnCtrlC: false,
|
|
15
38
|
screenMode: "alternate-screen",
|
|
@@ -22,8 +45,12 @@ const renderer = await createCliRenderer({
|
|
|
22
45
|
|
|
23
46
|
process.stdout.write(FOCUS_REPORTING_ENABLE)
|
|
24
47
|
|
|
48
|
+
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
49
|
+
|
|
25
50
|
createRoot(renderer).render(
|
|
26
51
|
<RegistryProvider>
|
|
27
|
-
<
|
|
52
|
+
<KeymapProvider keymap={keymap}>
|
|
53
|
+
<App />
|
|
54
|
+
</KeymapProvider>
|
|
28
55
|
</RegistryProvider>,
|
|
29
56
|
)
|
package/src/mergeActions.ts
CHANGED
|
@@ -61,12 +61,7 @@ const mergeActionDefinitions = {
|
|
|
61
61
|
},
|
|
62
62
|
} as const satisfies Record<PullRequestMergeAction, MergeActionDefinition>
|
|
63
63
|
|
|
64
|
-
export const mergeActions =
|
|
65
|
-
mergeActionDefinitions.squash,
|
|
66
|
-
mergeActionDefinitions.auto,
|
|
67
|
-
mergeActionDefinitions["disable-auto"],
|
|
68
|
-
mergeActionDefinitions.admin,
|
|
69
|
-
] as const satisfies readonly MergeActionDefinition[]
|
|
64
|
+
export const mergeActions: readonly MergeActionDefinition[] = Object.values(mergeActionDefinitions)
|
|
70
65
|
|
|
71
66
|
export const availableMergeActions = (info: PullRequestMergeInfo | null): readonly MergeActionDefinition[] => {
|
|
72
67
|
if (!info) return []
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PullRequestItem } from "./domain.js"
|
|
2
|
+
|
|
3
|
+
export const mergeCachedDetails = (fresh: readonly PullRequestItem[], cached: readonly PullRequestItem[] | undefined) => {
|
|
4
|
+
if (!cached) return fresh
|
|
5
|
+
const cachedByUrl = new Map(cached.map((pullRequest) => [pullRequest.url, pullRequest]))
|
|
6
|
+
return fresh.map((pullRequest) => {
|
|
7
|
+
const cachedPullRequest = cachedByUrl.get(pullRequest.url)
|
|
8
|
+
if (!cachedPullRequest?.detailLoaded || cachedPullRequest.headRefOid !== pullRequest.headRefOid) return pullRequest
|
|
9
|
+
return {
|
|
10
|
+
...pullRequest,
|
|
11
|
+
body: cachedPullRequest.body,
|
|
12
|
+
labels: cachedPullRequest.labels,
|
|
13
|
+
additions: cachedPullRequest.additions,
|
|
14
|
+
deletions: cachedPullRequest.deletions,
|
|
15
|
+
changedFiles: cachedPullRequest.changedFiles,
|
|
16
|
+
detailLoaded: true,
|
|
17
|
+
} satisfies PullRequestItem
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { pullRequestQueueLabels, pullRequestQueueModes, type PullRequestQueueMode, type PullRequestUserQueueMode } from "./domain.js"
|
|
2
|
+
|
|
3
|
+
export type PullRequestView =
|
|
4
|
+
| { readonly _tag: "Repository"; readonly repository: string }
|
|
5
|
+
| { readonly _tag: "Queue"; readonly mode: PullRequestUserQueueMode; readonly repository: string | null }
|
|
6
|
+
|
|
7
|
+
export const initialPullRequestView = (repository: string | null): PullRequestView => repository
|
|
8
|
+
? { _tag: "Repository", repository }
|
|
9
|
+
: { _tag: "Queue", mode: "authored", repository: null }
|
|
10
|
+
|
|
11
|
+
export const viewMode = (view: PullRequestView): PullRequestQueueMode => view._tag === "Repository" ? "repository" : view.mode
|
|
12
|
+
|
|
13
|
+
export const viewRepository = (view: PullRequestView) => view.repository
|
|
14
|
+
|
|
15
|
+
export const viewCacheKey = (view: PullRequestView) => view._tag === "Repository" ? `repository:${view.repository}` : view.mode
|
|
16
|
+
|
|
17
|
+
export const viewEquals = (left: PullRequestView, right: PullRequestView) =>
|
|
18
|
+
left._tag === right._tag && viewMode(left) === viewMode(right) && left.repository === right.repository
|
|
19
|
+
|
|
20
|
+
export const activePullRequestViews = (view: PullRequestView): readonly PullRequestView[] => {
|
|
21
|
+
const repository = viewRepository(view)
|
|
22
|
+
return [
|
|
23
|
+
...(repository ? [{ _tag: "Repository" as const, repository }] : []),
|
|
24
|
+
...pullRequestQueueModes.map((mode) => ({ _tag: "Queue" as const, mode, repository })),
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const nextView = (view: PullRequestView, views: readonly PullRequestView[], delta: 1 | -1) => {
|
|
29
|
+
const index = Math.max(0, views.findIndex((candidate) => viewEquals(candidate, view)))
|
|
30
|
+
return views[(index + delta + views.length) % views.length]!
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const viewLabel = (view: PullRequestView) => view._tag === "Repository" ? view.repository : pullRequestQueueLabels[view.mode]
|
|
34
|
+
|
|
35
|
+
export const parseRepositoryInput = (input: string) => {
|
|
36
|
+
const trimmed = input.trim()
|
|
37
|
+
const urlMatch = trimmed.match(/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s?#]+)(?:[/?#].*)?$/i)
|
|
38
|
+
const shorthandMatch = trimmed.match(/^([^/\s]+)\/([^/\s]+)$/)
|
|
39
|
+
const match = urlMatch ?? shorthandMatch
|
|
40
|
+
if (!match) return null
|
|
41
|
+
const owner = match[1]!
|
|
42
|
+
const repo = match[2]!.replace(/\.git$/i, "")
|
|
43
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) return null
|
|
44
|
+
return `${owner}/${repo}`
|
|
45
|
+
}
|