@kitlangton/ghui 0.1.18 → 0.1.20

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.
@@ -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
+ }
@@ -0,0 +1,68 @@
1
+ export type CommandScope = "Global" | "View" | "Pull request" | "Diff" | "Navigation" | "System"
2
+
3
+ export interface AppCommand {
4
+ readonly id: string
5
+ readonly title: string
6
+ readonly scope: CommandScope
7
+ readonly run: () => void
8
+ readonly subtitle?: string
9
+ readonly shortcut?: string
10
+ readonly keywords?: readonly string[]
11
+ readonly disabledReason?: string | null
12
+ }
13
+
14
+ export const defineCommand = (command: AppCommand): AppCommand => command
15
+
16
+ export const commandEnabled = (command: AppCommand) => !command.disabledReason
17
+
18
+ const normalize = (text: string) => text.toLowerCase().replace(/[^a-z0-9#]+/g, " ").trim()
19
+
20
+ const acronym = (text: string) => normalize(text).split(" ").filter(Boolean).map((word) => word[0]).join("")
21
+
22
+ const fuzzyIncludes = (text: string, query: string) => {
23
+ let index = 0
24
+ for (const char of text) {
25
+ if (char === query[index]) index++
26
+ if (index >= query.length) return true
27
+ }
28
+ return query.length === 0
29
+ }
30
+
31
+ const commandSearchText = (command: AppCommand) => normalize([
32
+ command.title,
33
+ command.subtitle,
34
+ command.scope,
35
+ command.shortcut,
36
+ ...(command.keywords ?? []),
37
+ ].filter(Boolean).join(" "))
38
+
39
+ const commandScore = (command: AppCommand, query: string) => {
40
+ const normalizedQuery = normalize(query)
41
+ if (normalizedQuery.length === 0) return 0
42
+
43
+ const title = normalize(command.title)
44
+ const searchText = commandSearchText(command)
45
+ const titleAcronym = acronym(command.title)
46
+ if (title.startsWith(normalizedQuery)) return 0
47
+ if (searchText.startsWith(normalizedQuery)) return 1
48
+ if (title.includes(normalizedQuery)) return 2
49
+ if (searchText.includes(normalizedQuery)) return 3
50
+ if (titleAcronym.startsWith(normalizedQuery)) return 4
51
+ if (fuzzyIncludes(searchText, normalizedQuery.replaceAll(" ", ""))) return 5
52
+ return null
53
+ }
54
+
55
+ export const filterCommands = (commands: readonly AppCommand[], query: string) => {
56
+ return commands.flatMap((command, index) => {
57
+ const score = commandScore(command, query)
58
+ return score === null ? [] : [{ command, index, score }]
59
+ }).sort((left, right) => {
60
+ const enabled = Number(commandEnabled(right.command)) - Number(commandEnabled(left.command))
61
+ return enabled || left.score - right.score || left.index - right.index
62
+ }).map(({ command }) => command)
63
+ }
64
+
65
+ export const clampCommandIndex = (index: number, commands: readonly AppCommand[]) => {
66
+ if (commands.length === 0) return 0
67
+ return Math.max(0, Math.min(commands.length - 1, index))
68
+ }
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,18 +1,24 @@
1
+ import { Schema } from "effect"
2
+
3
+ export type LoadStatus = "loading" | "ready" | "error"
4
+
1
5
  export type PullRequestState = "open" | "closed" | "merged"
2
6
 
3
7
  export const pullRequestQueueModes = ["authored", "review", "assigned", "mentioned"] as const
4
-
5
- export type PullRequestQueueMode = typeof pullRequestQueueModes[number]
8
+ export type PullRequestUserQueueMode = (typeof pullRequestQueueModes)[number]
9
+ export type PullRequestQueueMode = "repository" | PullRequestUserQueueMode
6
10
 
7
11
  export const pullRequestQueueLabels = {
12
+ repository: "repository",
8
13
  authored: "authored",
9
14
  review: "review requested",
10
15
  assigned: "assigned",
11
16
  mentioned: "mentioned",
12
17
  } as const satisfies Record<PullRequestQueueMode, string>
13
18
 
14
- export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, author: string) => {
19
+ export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, author: string, repository: string | null) => {
15
20
  const qualifiers = {
21
+ repository: repository ? `repo:${repository}` : `author:${author}`,
16
22
  authored: `author:${author}`,
17
23
  review: "review-requested:@me",
18
24
  assigned: "assignee:@me",
@@ -23,9 +29,24 @@ export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, auth
23
29
 
24
30
  export type CheckConclusion = "success" | "failure" | "neutral" | "skipped" | "cancelled" | "timed_out"
25
31
 
32
+ export type CheckRunStatus = "completed" | "in_progress" | "queued" | "pending"
33
+
34
+ export type CheckRollupStatus = "passing" | "pending" | "failing" | "none"
35
+
36
+ export type ReviewStatus = "draft" | "approved" | "changes" | "review" | "none"
37
+
38
+ export type Mergeable = "mergeable" | "conflicting" | "unknown"
39
+
40
+ // DiffCommentSide is the only literal type still consumed at runtime — GitHubService
41
+ // uses it as a Schema inside PullRequestCommentSchema.
42
+ export const DiffCommentSide = Schema.Literals(["LEFT", "RIGHT"])
43
+ export type DiffCommentSide = Schema.Schema.Type<typeof DiffCommentSide>
44
+
45
+ export type PullRequestMergeAction = "squash" | "auto" | "admin" | "disable-auto"
46
+
26
47
  export interface CheckItem {
27
48
  readonly name: string
28
- readonly status: "completed" | "in_progress" | "queued" | "pending"
49
+ readonly status: CheckRunStatus
29
50
  readonly conclusion: CheckConclusion | null
30
51
  }
31
52
 
@@ -34,8 +55,6 @@ export interface PullRequestLabel {
34
55
  readonly color: string | null
35
56
  }
36
57
 
37
- export type DiffCommentSide = "LEFT" | "RIGHT"
38
-
39
58
  export interface CreatePullRequestCommentInput {
40
59
  readonly repository: string
41
60
  readonly number: number
@@ -69,8 +88,8 @@ export interface PullRequestItem {
69
88
  readonly deletions: number
70
89
  readonly changedFiles: number
71
90
  readonly state: PullRequestState
72
- readonly reviewStatus: "draft" | "approved" | "changes" | "review" | "none"
73
- readonly checkStatus: "passing" | "pending" | "failing" | "none"
91
+ readonly reviewStatus: ReviewStatus
92
+ readonly checkStatus: CheckRollupStatus
74
93
  readonly checkSummary: string | null
75
94
  readonly checks: readonly CheckItem[]
76
95
  readonly autoMergeEnabled: boolean
@@ -80,17 +99,28 @@ export interface PullRequestItem {
80
99
  readonly url: string
81
100
  }
82
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
+
83
115
  export interface PullRequestMergeInfo {
84
116
  readonly repository: string
85
117
  readonly number: number
86
118
  readonly title: string
87
119
  readonly state: PullRequestState
88
120
  readonly isDraft: boolean
89
- readonly mergeable: "mergeable" | "conflicting" | "unknown"
90
- readonly reviewStatus: PullRequestItem["reviewStatus"]
91
- readonly checkStatus: PullRequestItem["checkStatus"]
121
+ readonly mergeable: Mergeable
122
+ readonly reviewStatus: ReviewStatus
123
+ readonly checkStatus: CheckRollupStatus
92
124
  readonly checkSummary: string | null
93
125
  readonly autoMergeEnabled: boolean
94
126
  }
95
-
96
- export type PullRequestMergeAction = "squash" | "auto" | "admin" | "disable-auto"
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,36 @@
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 { App } from "./App.js"
7
6
 
8
7
  process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
9
8
 
9
+ addDefaultParsers([
10
+ {
11
+ filetype: "bash",
12
+ aliases: ["sh", "shell", "zsh", "ksh"],
13
+ wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.25.1/tree-sitter-bash.wasm",
14
+ queries: {
15
+ highlights: ["https://raw.githubusercontent.com/tree-sitter/tree-sitter-bash/v0.25.1/queries/highlights.scm"],
16
+ },
17
+ },
18
+ ])
19
+
10
20
  const FOCUS_REPORTING_ENABLE = "\x1b[?1004h"
11
21
  const FOCUS_REPORTING_DISABLE = "\x1b[?1004l"
12
22
 
23
+ const paletteDetector = createTerminalPalette(process.stdin, process.stdout)
24
+ const [terminalColors, { setSystemThemeColors }, { App }] = await Promise.all([
25
+ paletteDetector.detect({ timeout: 150 }).catch(() => null).finally(() => paletteDetector.cleanup()),
26
+ import("./ui/colors.js"),
27
+ import("./App.js"),
28
+ ])
29
+
30
+ if (terminalColors) {
31
+ setSystemThemeColors(terminalColors)
32
+ }
33
+
13
34
  const renderer = await createCliRenderer({
14
35
  exitOnCtrlC: false,
15
36
  screenMode: "alternate-screen",
@@ -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
+ }
@@ -0,0 +1,22 @@
1
+ import { Context, Effect, Layer } from "effect"
2
+ import type { PullRequestItem } from "../domain.js"
3
+ import { CommandRunner, type CommandError } from "./CommandRunner.js"
4
+
5
+ export class BrowserOpener extends Context.Service<BrowserOpener, {
6
+ readonly openPullRequest: (pullRequest: PullRequestItem) => Effect.Effect<void, CommandError>
7
+ }>()("ghui/BrowserOpener") {
8
+ static readonly layerNoDeps = Layer.effect(
9
+ BrowserOpener,
10
+ Effect.gen(function*() {
11
+ const command = yield* CommandRunner
12
+
13
+ const openPullRequest = Effect.fn("BrowserOpener.openPullRequest")(function*(pullRequest: PullRequestItem) {
14
+ yield* command.run("gh", ["pr", "view", String(pullRequest.number), "--repo", pullRequest.repository, "--web"])
15
+ })
16
+
17
+ return BrowserOpener.of({ openPullRequest })
18
+ }),
19
+ )
20
+
21
+ static readonly layer = BrowserOpener.layerNoDeps.pipe(Layer.provide(CommandRunner.layer))
22
+ }