@kitlangton/ghui 0.2.1 → 0.3.2

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.
Files changed (53) hide show
  1. package/README.md +1 -4
  2. package/bin/ghui.js +6 -1
  3. package/dist/index.js +9419 -0
  4. package/package.json +7 -4
  5. package/src/App.tsx +0 -2604
  6. package/src/appCommands.ts +0 -384
  7. package/src/commands.ts +0 -73
  8. package/src/config.ts +0 -28
  9. package/src/date.ts +0 -20
  10. package/src/domain.ts +0 -139
  11. package/src/errors.ts +0 -10
  12. package/src/index.tsx +0 -50
  13. package/src/keyboard/opentuiAdapter.ts +0 -43
  14. package/src/keymap/all.ts +0 -103
  15. package/src/keymap/closeModal.ts +0 -13
  16. package/src/keymap/commandPalette.ts +0 -16
  17. package/src/keymap/commentModal.ts +0 -73
  18. package/src/keymap/commentThreadModal.ts +0 -24
  19. package/src/keymap/detailView.ts +0 -30
  20. package/src/keymap/diffView.ts +0 -91
  21. package/src/keymap/filterMode.ts +0 -13
  22. package/src/keymap/helpers.ts +0 -24
  23. package/src/keymap/labelModal.ts +0 -16
  24. package/src/keymap/listNav.ts +0 -119
  25. package/src/keymap/mergeModal.ts +0 -23
  26. package/src/keymap/openRepositoryModal.ts +0 -13
  27. package/src/keymap/themeModal.ts +0 -49
  28. package/src/mergeActions.ts +0 -88
  29. package/src/observability.ts +0 -46
  30. package/src/pullRequestCache.ts +0 -19
  31. package/src/pullRequestViews.ts +0 -45
  32. package/src/services/BrowserOpener.ts +0 -22
  33. package/src/services/Clipboard.ts +0 -46
  34. package/src/services/CommandRunner.ts +0 -91
  35. package/src/services/GitHubService.ts +0 -741
  36. package/src/services/MockGitHubService.ts +0 -181
  37. package/src/themeStore.ts +0 -60
  38. package/src/ui/CommandPalette.tsx +0 -216
  39. package/src/ui/DetailsPane.tsx +0 -656
  40. package/src/ui/FooterHints.tsx +0 -90
  41. package/src/ui/LoadingLogo.tsx +0 -75
  42. package/src/ui/PullRequestDiffPane.tsx +0 -248
  43. package/src/ui/PullRequestList.tsx +0 -210
  44. package/src/ui/colors.ts +0 -814
  45. package/src/ui/commentEditor.ts +0 -126
  46. package/src/ui/comments.tsx +0 -143
  47. package/src/ui/diff.ts +0 -650
  48. package/src/ui/diffStats.tsx +0 -25
  49. package/src/ui/modals.tsx +0 -614
  50. package/src/ui/primitives.tsx +0 -205
  51. package/src/ui/pullRequests.ts +0 -106
  52. package/src/ui/singleLineInput.ts +0 -26
  53. package/src/ui/spinner.ts +0 -1
@@ -1,49 +0,0 @@
1
- import { context } from "@ghui/keymap"
2
-
3
- export interface ThemeModalCtx {
4
- readonly filterMode: boolean
5
- readonly hasFilteredResults: boolean
6
- readonly closeWithoutSaving: () => void
7
- readonly clearFilter: () => void
8
- readonly enterFilterMode: () => void
9
- readonly confirmSelection: () => void
10
- readonly moveSelection: (delta: -1 | 1) => void
11
- }
12
-
13
- const Theme = context<ThemeModalCtx>()
14
-
15
- export const themeModalKeymap = Theme(
16
- {
17
- id: "theme-modal.escape",
18
- title: "Cancel",
19
- keys: ["escape"],
20
- run: (s) => {
21
- if (s.filterMode) s.clearFilter()
22
- else s.closeWithoutSaving()
23
- },
24
- },
25
- { id: "theme-modal.filter", title: "Filter themes", keys: ["/"], run: (s) => s.enterFilterMode() },
26
- {
27
- id: "theme-modal.confirm",
28
- title: "Apply theme",
29
- keys: ["return"],
30
- enabled: (s) => s.filterMode && !s.hasFilteredResults ? "No matching themes." : true,
31
- run: (s) => s.confirmSelection(),
32
- },
33
- { id: "theme-modal.up-arrow", title: "Up", keys: ["up"], run: (s) => s.moveSelection(-1) },
34
- { id: "theme-modal.down-arrow", title: "Down", keys: ["down"], run: (s) => s.moveSelection(1) },
35
- {
36
- id: "theme-modal.up-letter",
37
- title: "Up",
38
- keys: ["k"],
39
- when: (s) => !s.filterMode,
40
- run: (s) => s.moveSelection(-1),
41
- },
42
- {
43
- id: "theme-modal.down-letter",
44
- title: "Down",
45
- keys: ["j"],
46
- when: (s) => !s.filterMode,
47
- run: (s) => s.moveSelection(1),
48
- },
49
- )
@@ -1,88 +0,0 @@
1
- import type { PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo, PullRequestState } from "./domain.js"
2
-
3
- export interface MergeActionDefinition {
4
- readonly action: PullRequestMergeAction
5
- readonly title: string
6
- readonly description: string
7
- readonly cliArgs: readonly string[]
8
- readonly pastTense: string
9
- readonly danger?: boolean
10
- readonly refreshOnSuccess?: boolean
11
- readonly optimisticState?: PullRequestState
12
- readonly optimisticAutoMergeEnabled?: boolean
13
- readonly isAvailable: (info: PullRequestMergeInfo) => boolean
14
- }
15
-
16
- const isCleanlyMergeable = (info: PullRequestMergeInfo) =>
17
- info.state === "open" &&
18
- !info.isDraft &&
19
- info.mergeable === "mergeable" &&
20
- info.reviewStatus !== "changes" &&
21
- info.reviewStatus !== "review" &&
22
- info.checkStatus !== "pending" &&
23
- info.checkStatus !== "failing"
24
-
25
- const mergeActionDefinitions = {
26
- squash: {
27
- action: "squash",
28
- title: "Squash merge now",
29
- description: "Merge this pull request and delete the branch.",
30
- cliArgs: ["--squash", "--delete-branch"],
31
- pastTense: "Merged",
32
- refreshOnSuccess: true,
33
- optimisticState: "merged",
34
- isAvailable: isCleanlyMergeable,
35
- },
36
- auto: {
37
- action: "auto",
38
- title: "Enable auto-merge",
39
- description: "Squash merge automatically after GitHub requirements pass.",
40
- cliArgs: ["--squash", "--auto", "--delete-branch"],
41
- pastTense: "Enabled auto-merge",
42
- optimisticAutoMergeEnabled: true,
43
- isAvailable: (info) => info.state === "open" && !info.autoMergeEnabled && !info.isDraft && info.mergeable !== "conflicting",
44
- },
45
- "disable-auto": {
46
- action: "disable-auto",
47
- title: "Disable auto-merge",
48
- description: "Cancel the pending GitHub auto-merge request.",
49
- cliArgs: ["--disable-auto"],
50
- pastTense: "Disabled auto-merge",
51
- optimisticAutoMergeEnabled: false,
52
- isAvailable: (info) => info.state === "open" && info.autoMergeEnabled,
53
- },
54
- admin: {
55
- action: "admin",
56
- title: "Admin override merge",
57
- description: "Bypass unmet merge requirements with --admin.",
58
- cliArgs: ["--squash", "--admin", "--delete-branch"],
59
- pastTense: "Admin merged",
60
- danger: true,
61
- refreshOnSuccess: true,
62
- optimisticState: "merged",
63
- isAvailable: (info) => info.state === "open" && !info.isDraft && info.mergeable !== "conflicting",
64
- },
65
- } as const satisfies Record<PullRequestMergeAction, MergeActionDefinition>
66
-
67
- export const mergeActions: readonly MergeActionDefinition[] = Object.values(mergeActionDefinitions)
68
-
69
- export const availableMergeActions = (info: PullRequestMergeInfo | null): readonly MergeActionDefinition[] => {
70
- if (!info) return []
71
- return mergeActions.filter((action) => action.isAvailable(info))
72
- }
73
-
74
- export const getMergeActionDefinition = (action: PullRequestMergeAction): MergeActionDefinition =>
75
- mergeActionDefinitions[action]
76
-
77
- export const mergeInfoFromPullRequest = (pullRequest: PullRequestItem): PullRequestMergeInfo => ({
78
- repository: pullRequest.repository,
79
- number: pullRequest.number,
80
- title: pullRequest.title,
81
- state: pullRequest.state,
82
- isDraft: pullRequest.reviewStatus === "draft",
83
- mergeable: "unknown",
84
- reviewStatus: pullRequest.reviewStatus,
85
- checkStatus: pullRequest.checkStatus,
86
- checkSummary: pullRequest.checkSummary,
87
- autoMergeEnabled: pullRequest.autoMergeEnabled,
88
- })
@@ -1,46 +0,0 @@
1
- import { Config, Effect, Layer } from "effect"
2
- import { FetchHttpClient } from "effect/unstable/http"
3
- import { OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"
4
-
5
- const observabilityConfig = Config.all({
6
- endpoint: Config.string("GHUI_OTLP_ENDPOINT").pipe(
7
- Config.withDefault(""),
8
- Config.map((value) => value.trim()),
9
- ),
10
- motelPort: Config.string("GHUI_MOTEL_PORT").pipe(
11
- Config.withDefault(""),
12
- Config.map((value) => value.trim()),
13
- ),
14
- })
15
-
16
- const resource = {
17
- serviceName: "ghui",
18
- serviceVersion: "local",
19
- }
20
-
21
- export const Observability = {
22
- layer: Layer.unwrap(Effect.gen(function*() {
23
- const { endpoint, motelPort } = yield* observabilityConfig
24
- const baseUrl = endpoint || (motelPort ? `http://127.0.0.1:${motelPort}` : null)
25
-
26
- return baseUrl === null
27
- ? Layer.empty
28
- : Layer.merge(
29
- OtlpTracer.layer({
30
- url: `${baseUrl}/v1/traces`,
31
- exportInterval: "500 millis",
32
- shutdownTimeout: "1 second",
33
- resource,
34
- }),
35
- OtlpLogger.layer({
36
- url: `${baseUrl}/v1/logs`,
37
- exportInterval: "500 millis",
38
- shutdownTimeout: "1 second",
39
- resource,
40
- }),
41
- ).pipe(
42
- Layer.provide(OtlpSerialization.layerJson),
43
- Layer.provide(FetchHttpClient.layer),
44
- )
45
- })),
46
- } as const
@@ -1,19 +0,0 @@
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
- }
@@ -1,45 +0,0 @@
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
- }
@@ -1,22 +0,0 @@
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
- }
@@ -1,46 +0,0 @@
1
- import { Context, Effect, Layer, Schema } from "effect"
2
- import { CommandRunner } from "./CommandRunner.js"
3
-
4
- export class ClipboardError extends Schema.TaggedErrorClass<ClipboardError>()("ClipboardError", {
5
- detail: Schema.String,
6
- }) {}
7
-
8
- const clipboardCommands: readonly (readonly [string, ...readonly string[]])[] =
9
- process.platform === "darwin" ? [["pbcopy"]]
10
- : process.platform === "linux" ? [
11
- ...(process.env.WAYLAND_DISPLAY ? [["wl-copy"] as const] : []),
12
- ["xclip", "-selection", "clipboard"] as const,
13
- ["xsel", "--clipboard", "--input"] as const,
14
- ]
15
- : []
16
-
17
- const installHint = process.platform === "linux" ? " Install wl-clipboard, xclip, or xsel." : ""
18
- const unavailableDetail = `Clipboard is not available.${installHint}`
19
-
20
- export class Clipboard extends Context.Service<Clipboard, {
21
- readonly copy: (text: string) => Effect.Effect<void, ClipboardError>
22
- }>()("ghui/Clipboard") {
23
- static readonly layerNoDeps = Layer.effect(
24
- Clipboard,
25
- Effect.gen(function*() {
26
- const command = yield* CommandRunner
27
-
28
- const copy = Effect.fn("Clipboard.copy")(function*(text: string) {
29
- if (clipboardCommands.length === 0) {
30
- return yield* new ClipboardError({ detail: unavailableDetail })
31
- }
32
- let lastDetail = ""
33
- for (const [cmd, ...args] of clipboardCommands) {
34
- const result = yield* command.run(cmd, args, { stdin: text }).pipe(Effect.result)
35
- if (result._tag === "Success") return
36
- lastDetail = result.failure.detail
37
- }
38
- return yield* new ClipboardError({ detail: lastDetail || unavailableDetail })
39
- })
40
-
41
- return Clipboard.of({ copy })
42
- }),
43
- )
44
-
45
- static readonly layer = Clipboard.layerNoDeps.pipe(Layer.provide(CommandRunner.layer))
46
- }
@@ -1,91 +0,0 @@
1
- import { Context, Effect, Layer, Schema } from "effect"
2
-
3
- export interface CommandResult {
4
- readonly stdout: string
5
- readonly stderr: string
6
- readonly exitCode: number
7
- }
8
-
9
- export interface RunOptions {
10
- readonly stdin?: string
11
- }
12
-
13
- export class CommandError extends Schema.TaggedErrorClass<CommandError>()("CommandError", {
14
- command: Schema.String,
15
- args: Schema.Array(Schema.String),
16
- detail: Schema.String,
17
- cause: Schema.Defect,
18
- }) {}
19
-
20
- export class JsonParseError extends Schema.TaggedErrorClass<JsonParseError>()("JsonParseError", {
21
- command: Schema.String,
22
- args: Schema.Array(Schema.String),
23
- stdout: Schema.String,
24
- cause: Schema.Defect,
25
- }) {}
26
-
27
- const readStream = async (stream: ReadableStream | null | undefined) => {
28
- if (!stream) return ""
29
- return Bun.readableStreamToText(stream)
30
- }
31
-
32
- export class CommandRunner extends Context.Service<CommandRunner, {
33
- readonly run: (command: string, args: readonly string[], options?: RunOptions) => Effect.Effect<CommandResult, CommandError>
34
- readonly runSchema: <S extends Schema.Top>(schema: S, command: string, args: readonly string[]) =>
35
- Effect.Effect<S["Type"], CommandError | JsonParseError | Schema.SchemaError, S["DecodingServices"]>
36
- }>()("ghui/CommandRunner") {
37
- static readonly layer = Layer.effect(
38
- CommandRunner,
39
- Effect.gen(function*() {
40
- const runProcess = Effect.fn("CommandRunner.runProcess")((command: string, args: readonly string[], stdin: string | undefined) =>
41
- Effect.tryPromise({
42
- async try() {
43
- const proc = Bun.spawn({
44
- cmd: [command, ...args],
45
- stdin: stdin === undefined ? "ignore" : "pipe",
46
- stdout: "pipe",
47
- stderr: "pipe",
48
- })
49
- if (stdin !== undefined && proc.stdin) {
50
- proc.stdin.write(stdin)
51
- proc.stdin.end()
52
- }
53
-
54
- const [exitCode, stdout, stderr] = await Promise.all([proc.exited, readStream(proc.stdout), readStream(proc.stderr)])
55
- return { stdout, stderr, exitCode }
56
- },
57
- catch: (cause) => new CommandError({ command, args: [...args], detail: `Failed to run ${command}`, cause }),
58
- })
59
- )
60
-
61
- const run = Effect.fn("CommandRunner.run")(function*(command: string, args: readonly string[], options?: RunOptions) {
62
- const result = yield* runProcess(command, args, options?.stdin).pipe(Effect.withSpan("ghui.command.runProcess", {
63
- attributes: {
64
- "process.command": command,
65
- "process.argv.count": args.length,
66
- },
67
- }))
68
- if (result.exitCode !== 0) {
69
- const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`
70
- return yield* new CommandError({ command, args: [...args], detail, cause: detail })
71
- }
72
- return result
73
- })
74
-
75
- const runJson = Effect.fn("CommandRunner.runJson")(function*<A>(command: string, args: readonly string[]) {
76
- const result = yield* run(command, args)
77
- return yield* Effect.try({
78
- try: () => JSON.parse(result.stdout) as A,
79
- catch: (cause) => new JsonParseError({ command, args: [...args], stdout: result.stdout, cause }),
80
- })
81
- })
82
-
83
- const runSchema = Effect.fn("CommandRunner.runSchema")(function*<S extends Schema.Top>(schema: S, command: string, args: readonly string[]) {
84
- const value = yield* runJson<unknown>(command, args)
85
- return yield* Schema.decodeUnknownEffect(schema)(value)
86
- })
87
-
88
- return CommandRunner.of({ run, runSchema })
89
- }),
90
- )
91
- }