@kitlangton/ghui 0.3.0 → 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 (54) hide show
  1. package/bin/ghui.js +6 -1
  2. package/dist/index.js +9419 -0
  3. package/package.json +7 -4
  4. package/src/App.tsx +0 -2779
  5. package/src/appCommands.ts +0 -409
  6. package/src/commands.ts +0 -73
  7. package/src/config.ts +0 -20
  8. package/src/date.ts +0 -20
  9. package/src/domain.ts +0 -149
  10. package/src/errors.ts +0 -10
  11. package/src/index.tsx +0 -50
  12. package/src/keyboard/opentuiAdapter.ts +0 -43
  13. package/src/keymap/all.ts +0 -116
  14. package/src/keymap/changedFilesModal.ts +0 -23
  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 -93
  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/submitReviewModal.ts +0 -48
  28. package/src/keymap/themeModal.ts +0 -49
  29. package/src/mergeActions.ts +0 -88
  30. package/src/observability.ts +0 -46
  31. package/src/pullRequestCache.ts +0 -19
  32. package/src/pullRequestViews.ts +0 -43
  33. package/src/services/BrowserOpener.ts +0 -22
  34. package/src/services/Clipboard.ts +0 -46
  35. package/src/services/CommandRunner.ts +0 -91
  36. package/src/services/GitHubService.ts +0 -756
  37. package/src/services/MockGitHubService.ts +0 -182
  38. package/src/themeStore.ts +0 -60
  39. package/src/ui/CommandPalette.tsx +0 -176
  40. package/src/ui/DetailsPane.tsx +0 -656
  41. package/src/ui/FooterHints.tsx +0 -90
  42. package/src/ui/LoadingLogo.tsx +0 -75
  43. package/src/ui/PullRequestDiffPane.tsx +0 -249
  44. package/src/ui/PullRequestList.tsx +0 -194
  45. package/src/ui/colors.ts +0 -821
  46. package/src/ui/commentEditor.ts +0 -126
  47. package/src/ui/comments.tsx +0 -143
  48. package/src/ui/diff.ts +0 -650
  49. package/src/ui/diffStats.tsx +0 -25
  50. package/src/ui/modals.tsx +0 -964
  51. package/src/ui/primitives.tsx +0 -350
  52. package/src/ui/pullRequests.ts +0 -106
  53. package/src/ui/singleLineInput.ts +0 -26
  54. package/src/ui/spinner.ts +0 -1
@@ -1,182 +0,0 @@
1
- import { Effect, Layer } from "effect"
2
- import type { CheckItem, CreatePullRequestCommentInput, Mergeable, PullRequestConversationItem, PullRequestItem, PullRequestLabel, PullRequestMergeInfo, PullRequestPage, PullRequestQueueMode, PullRequestReviewComment, ReviewStatus } from "../domain.js"
3
- import { GitHubService } from "./GitHubService.js"
4
-
5
- export interface MockOptions {
6
- readonly prCount: number
7
- readonly repoCount?: number
8
- readonly username?: string
9
- readonly seed?: number
10
- }
11
-
12
- const REVIEW_CYCLE: readonly ReviewStatus[] = ["approved", "changes", "review", "none", "draft"]
13
- const MERGEABLE_CYCLE: readonly Mergeable[] = ["mergeable", "conflicting", "unknown"]
14
-
15
- const synthCheckSummary = (passed: number, total: number): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
16
- const checks: readonly CheckItem[] = Array.from({ length: total }, (_, index) => ({
17
- name: `check-${index}`,
18
- status: "completed",
19
- conclusion: index < passed ? "success" : "failure",
20
- }))
21
- if (total === 0) return { checkStatus: "none", checkSummary: null, checks: [] }
22
- if (passed === total) return { checkStatus: "passing", checkSummary: `${passed}/${total}`, checks }
23
- return { checkStatus: "failing", checkSummary: `${passed}/${total}`, checks }
24
- }
25
-
26
- const synthLabels = (index: number): readonly PullRequestLabel[] => {
27
- if (index % 5 === 0) return [{ name: "bug", color: "#d73a4a" }]
28
- if (index % 7 === 0) return [{ name: "enhancement", color: "#a2eeef" }, { name: "tests", color: "#0e8a16" }]
29
- return []
30
- }
31
-
32
- const buildPullRequest = (index: number, options: Required<MockOptions>): PullRequestItem => {
33
- const repoIndex = index % options.repoCount
34
- const repository = `mock-org/repo-${repoIndex}`
35
- const number = 1000 + index
36
- const total = 8 + (index % 5)
37
- const passed = total - (index % 3 === 0 ? 1 : 0)
38
- const review = REVIEW_CYCLE[index % REVIEW_CYCLE.length]!
39
- const createdAt = new Date(Date.now() - index * 86_400_000)
40
-
41
- return {
42
- repository,
43
- author: options.username,
44
- headRefOid: `deadbeef${index.toString(16).padStart(8, "0")}`,
45
- number,
46
- title: `Mock PR ${number}: example change ${index}`,
47
- body: `This is mock pull request #${number}.\n\nLine A.\nLine B.`,
48
- labels: synthLabels(index),
49
- additions: 10 + index,
50
- deletions: 5 + (index % 11),
51
- changedFiles: 1 + (index % 7),
52
- state: "open",
53
- reviewStatus: review,
54
- ...synthCheckSummary(passed, total),
55
- autoMergeEnabled: index % 11 === 0,
56
- detailLoaded: true,
57
- createdAt,
58
- closedAt: null,
59
- url: `https://github.com/${repository}/pull/${number}`,
60
- }
61
- }
62
-
63
- export const buildMockPullRequests = (options: MockOptions): readonly PullRequestItem[] => {
64
- const resolved: Required<MockOptions> = {
65
- prCount: options.prCount,
66
- repoCount: options.repoCount ?? 4,
67
- username: options.username ?? "mock-user",
68
- seed: options.seed ?? 0,
69
- }
70
- return Array.from({ length: resolved.prCount }, (_, index) => buildPullRequest(index, resolved))
71
- }
72
-
73
- const filterByView = (mode: PullRequestQueueMode, repository: string | null, source: readonly PullRequestItem[]) => {
74
- if (mode === "repository") return repository ? source.filter((item) => item.repository === repository) : []
75
- return source
76
- }
77
-
78
- const pageItems = (source: readonly PullRequestItem[], cursor: string | null, pageSize: number): PullRequestPage => {
79
- const start = cursor ? Number.parseInt(cursor, 10) : 0
80
- const safeStart = Number.isFinite(start) && start >= 0 ? start : 0
81
- const safePageSize = Math.max(1, Math.min(100, pageSize))
82
- const end = Math.min(source.length, safeStart + safePageSize)
83
- return {
84
- items: source.slice(safeStart, end),
85
- endCursor: end > safeStart ? String(end) : null,
86
- hasNextPage: end < source.length,
87
- }
88
- }
89
-
90
- const mockDiff = `diff --git a/src/mockDiff.ts b/src/mockDiff.ts
91
- --- a/src/mockDiff.ts
92
- +++ b/src/mockDiff.ts
93
- @@ -1,6 +1,6 @@
94
- export const before = true
95
- -const oldOne = 1
96
- +const newOne = 1
97
- - sameName()
98
- + sameName()
99
- -const oldTwo = 2
100
- +const newTwo = 2
101
- export const after = true`
102
-
103
- export const MockGitHubService = {
104
- layer: (options: MockOptions) => {
105
- const items = buildMockPullRequests(options)
106
- const username = options.username ?? "mock-user"
107
- const summaryItems = items.map((item) => ({
108
- ...item,
109
- body: "",
110
- labels: [],
111
- additions: 0,
112
- deletions: 0,
113
- changedFiles: 0,
114
- detailLoaded: false,
115
- } satisfies PullRequestItem))
116
- const findPullRequest = (repository: string, number: number) => items.find((item) => item.repository === repository && item.number === number) ?? items[0]!
117
- const conversationItems = (repository: string, number: number): readonly PullRequestConversationItem[] => [
118
- {
119
- _tag: "comment",
120
- id: `mock-comment:${repository}:${number}:1`,
121
- author: "mock-reviewer",
122
- body: `Top-level discussion for #${number}. This should appear after the summary with its own separator.`,
123
- createdAt: new Date(Date.now() - 3_600_000),
124
- url: null,
125
- },
126
- {
127
- _tag: "review-comment",
128
- id: `mock-review:${repository}:${number}:1`,
129
- author: "mock-reviewer",
130
- body: "Inline review comment rendered in the same conversation stream.",
131
- createdAt: new Date(Date.now() - 1_800_000),
132
- url: null,
133
- path: "src/App.tsx",
134
- line: 42,
135
- side: "RIGHT",
136
- },
137
- ]
138
-
139
- return Layer.succeed(
140
- GitHubService,
141
- GitHubService.of({
142
- listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, summaryItems)),
143
- listOpenPullRequestPage: (input) => Effect.succeed(pageItems(filterByView(input.mode, input.repository, summaryItems), input.cursor, input.pageSize)),
144
- listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, items)),
145
- getPullRequestDetails: (repository, number) => Effect.succeed(findPullRequest(repository, number)),
146
- getAuthenticatedUser: () => Effect.succeed(username),
147
- getPullRequestDiff: (_repo, _number) => Effect.succeed(mockDiff),
148
- listPullRequestComments: (_repo, _number) => Effect.succeed([] as readonly PullRequestReviewComment[]),
149
- listPullRequestConversation: (repository, number) => Effect.succeed(conversationItems(repository, number)),
150
- getPullRequestMergeInfo: (repository, number) => Effect.succeed({
151
- repository,
152
- number,
153
- title: `Mock PR ${number}`,
154
- state: "open",
155
- isDraft: false,
156
- mergeable: MERGEABLE_CYCLE[number % MERGEABLE_CYCLE.length]!,
157
- reviewStatus: "approved",
158
- checkStatus: "passing",
159
- checkSummary: "10/10",
160
- autoMergeEnabled: false,
161
- } satisfies PullRequestMergeInfo),
162
- mergePullRequest: () => Effect.void,
163
- closePullRequest: () => Effect.void,
164
- createPullRequestComment: (input: CreatePullRequestCommentInput) => Effect.succeed({
165
- id: `mock:${Date.now()}`,
166
- path: input.path,
167
- line: input.line,
168
- side: input.side,
169
- author: username,
170
- body: input.body,
171
- createdAt: new Date(),
172
- url: null,
173
- } satisfies PullRequestReviewComment),
174
- submitPullRequestReview: () => Effect.void,
175
- toggleDraftStatus: () => Effect.void,
176
- listRepoLabels: () => Effect.succeed([]),
177
- addPullRequestLabel: () => Effect.void,
178
- removePullRequestLabel: () => Effect.void,
179
- }),
180
- )
181
- },
182
- }
package/src/themeStore.ts DELETED
@@ -1,60 +0,0 @@
1
- import { mkdir } from "node:fs/promises"
2
- import { homedir } from "node:os"
3
- import { dirname, join } from "node:path"
4
- import { Effect, Schema } from "effect"
5
- import { isThemeId, type ThemeId } from "./ui/colors.js"
6
- import { DiffWhitespaceMode } from "./ui/diff.js"
7
-
8
- interface StoredConfig {
9
- readonly theme?: unknown
10
- readonly diffWhitespaceMode?: unknown
11
- }
12
-
13
- const configDirectory = () => {
14
- if (process.env.GHUI_CONFIG_DIR) return process.env.GHUI_CONFIG_DIR
15
- if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "ghui")
16
- if (process.platform === "win32" && process.env.APPDATA) return join(process.env.APPDATA, "ghui")
17
- return join(homedir(), ".config", "ghui")
18
- }
19
-
20
- export const configPath = () => join(configDirectory(), "config.json")
21
-
22
- const parseConfig = (text: string): StoredConfig => {
23
- const value = JSON.parse(text) as unknown
24
- return value && typeof value === "object" ? value : {}
25
- }
26
-
27
- const readStoredConfig = async () => {
28
- const file = Bun.file(configPath())
29
- return await file.exists() ? parseConfig(await file.text()) : {}
30
- }
31
-
32
- const writeStoredConfig = async (config: StoredConfig) => {
33
- const path = configPath()
34
- await mkdir(dirname(path), { recursive: true })
35
- await Bun.write(path, `${JSON.stringify(config, null, "\t")}\n`)
36
- }
37
-
38
- export const loadStoredThemeId: Effect.Effect<ThemeId> = Effect.catchCause(Effect.tryPromise(async () => {
39
- const config = await readStoredConfig()
40
- return isThemeId(config.theme) ? config.theme : "ghui"
41
- }), () => Effect.succeed("ghui" satisfies ThemeId))
42
-
43
- export const loadStoredDiffWhitespaceMode: Effect.Effect<DiffWhitespaceMode> = Effect.catchCause(Effect.tryPromise(async () => {
44
- const config = await readStoredConfig()
45
- return Schema.is(DiffWhitespaceMode)(config.diffWhitespaceMode) ? config.diffWhitespaceMode : "ignore"
46
- }), () => Effect.succeed("ignore" satisfies DiffWhitespaceMode))
47
-
48
- export const saveStoredThemeId = (theme: ThemeId): Effect.Effect<void> => Effect.tryPromise(async () => {
49
- const config = await readStoredConfig()
50
- if (config.theme === theme) return
51
-
52
- await writeStoredConfig({ ...config, theme })
53
- })
54
-
55
- export const saveStoredDiffWhitespaceMode = (diffWhitespaceMode: DiffWhitespaceMode): Effect.Effect<void> => Effect.tryPromise(async () => {
56
- const config = await readStoredConfig()
57
- if (config.diffWhitespaceMode === diffWhitespaceMode) return
58
-
59
- await writeStoredConfig({ ...config, diffWhitespaceMode })
60
- })
@@ -1,176 +0,0 @@
1
- import { TextAttributes, type MouseEvent } from "@opentui/core"
2
- import { useEffect, useMemo, useState } from "react"
3
- import type { AppCommand } from "../commands.js"
4
- import { clampCommandIndex } from "../commands.js"
5
- import { colors } from "./colors.js"
6
- import { scrollTopForVisibleLine } from "./diff.js"
7
- import { centerCell, Filler, fitCell, HintRow, PlainLine, searchModalDims, SearchModalFrame, TextLine, trimCell } from "./primitives.js"
8
-
9
- const scopeLabels = {
10
- Global: "App",
11
- View: "View",
12
- "Pull request": "Pull Request",
13
- Diff: "Diff",
14
- Navigation: "Navigation",
15
- System: "System",
16
- } as const satisfies Record<AppCommand["scope"], string>
17
-
18
- export type CommandPaletteRow =
19
- | { readonly _tag: "section"; readonly scope: AppCommand["scope"] }
20
- | { readonly _tag: "spacer" }
21
- | { readonly _tag: "command"; readonly command: AppCommand; readonly commandIndex: number }
22
-
23
- export const buildCommandPaletteRows = (commands: readonly AppCommand[]): readonly CommandPaletteRow[] => {
24
- const rows: CommandPaletteRow[] = []
25
- let previousScope: AppCommand["scope"] | null = null
26
- for (let commandIndex = 0; commandIndex < commands.length; commandIndex++) {
27
- const command = commands[commandIndex]!
28
- if (command.scope !== previousScope) {
29
- if (previousScope !== null) rows.push({ _tag: "spacer" })
30
- rows.push({ _tag: "section", scope: command.scope })
31
- previousScope = command.scope
32
- }
33
- rows.push({ _tag: "command", command, commandIndex })
34
- }
35
- return rows
36
- }
37
-
38
- export const commandPaletteSelectedRowIndex = (rows: readonly CommandPaletteRow[], selectedCommandIndex: number) =>
39
- Math.max(0, rows.findIndex((row) => row._tag === "command" && row.commandIndex === selectedCommandIndex))
40
-
41
- export const commandPaletteScrollTop = ({
42
- current,
43
- rowsLength,
44
- listHeight,
45
- selectedRowIndex,
46
- }: {
47
- readonly current: number
48
- readonly rowsLength: number
49
- readonly listHeight: number
50
- readonly selectedRowIndex: number
51
- }) => {
52
- if (rowsLength <= listHeight) return 0
53
- const maxScrollTop = Math.max(0, rowsLength - listHeight)
54
- const clampScrollTop = (value: number) => Math.max(0, Math.min(value, maxScrollTop))
55
- return clampScrollTop(scrollTopForVisibleLine(current, listHeight, selectedRowIndex, 0))
56
- }
57
-
58
- export const commandPaletteClampScrollTop = (rowsLength: number, listHeight: number, value: number) =>
59
- Math.max(0, Math.min(value, Math.max(0, rowsLength - listHeight)))
60
-
61
- export const CommandPalette = ({
62
- commands,
63
- query,
64
- selectedIndex,
65
- modalWidth,
66
- modalHeight,
67
- offsetLeft,
68
- offsetTop,
69
- onSelectCommandIndex,
70
- onRunCommand,
71
- }: {
72
- commands: readonly AppCommand[]
73
- query: string
74
- selectedIndex: number
75
- modalWidth: number
76
- modalHeight: number
77
- offsetLeft: number
78
- offsetTop: number
79
- onSelectCommandIndex: (index: number) => void
80
- onRunCommand: (command: AppCommand) => void
81
- }) => {
82
- const { bodyHeight: listHeight, rowWidth } = searchModalDims(modalWidth, modalHeight)
83
- const clampedIndex = clampCommandIndex(selectedIndex, commands)
84
- const [scrollTop, setScrollTop] = useState(0)
85
- const rows = useMemo(() => buildCommandPaletteRows(commands), [commands])
86
- const selectedRowIndex = commandPaletteSelectedRowIndex(rows, clampedIndex)
87
- const visibleRows = rows.slice(scrollTop, scrollTop + listHeight)
88
- const bottomPaddingRows = Math.max(0, listHeight - visibleRows.length)
89
- const countText = commands.length === 1 ? "1 command" : `${commands.length} commands`
90
- const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
91
- const emptyBottomRows = Math.max(0, listHeight - emptyTopRows - 1)
92
- const runCommandOnMouseDown = (command: AppCommand) => (event: MouseEvent) => {
93
- if (event.button !== 0) return
94
- event.preventDefault()
95
- event.stopPropagation()
96
- onRunCommand(command)
97
- }
98
- const selectCommandOnMouse = (commandIndex: number) => (event: MouseEvent) => {
99
- onSelectCommandIndex(commandIndex)
100
- event.stopPropagation()
101
- }
102
- const handleMouseScroll = (event: MouseEvent) => {
103
- if (!event.scroll || rows.length <= listHeight) return
104
- const delta = Math.max(1, Math.ceil(event.scroll.delta))
105
- const direction = event.scroll.direction === "down" || event.scroll.direction === "right" ? 1 : -1
106
- setScrollTop((current) => commandPaletteClampScrollTop(rows.length, listHeight, current + direction * delta))
107
- event.preventDefault()
108
- event.stopPropagation()
109
- }
110
- const content = rows.length === 0 ? (
111
- <>
112
- <Filler rows={emptyTopRows} prefix="top" />
113
- <PlainLine text={centerCell("No matching command", rowWidth)} fg={colors.muted} />
114
- <Filler rows={emptyBottomRows} prefix="bottom" />
115
- </>
116
- ) : (
117
- <>
118
- {visibleRows.map((row, index) => {
119
- const rowIndex = scrollTop + index
120
- if (row._tag === "spacer") {
121
- return <PlainLine key={`spacer-${rowIndex}`} text="" />
122
- }
123
- if (row._tag === "section") {
124
- return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
125
- }
126
-
127
- const { command, commandIndex } = row
128
- const isSelected = commandIndex === clampedIndex
129
- const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
130
- const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
131
- const trailingPadding = shortcut.length === 0 ? 0 : 1
132
- // Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
133
- const SELECTOR_WIDTH = 2
134
- const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
135
- const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
136
- const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
137
- const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
138
- const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
139
-
140
- return (
141
- <box key={command.id} height={1} onMouseDown={runCommandOnMouseDown(command)} onMouseMove={selectCommandOnMouse(commandIndex)} onMouseOver={selectCommandOnMouse(commandIndex)}>
142
- <TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
143
- <span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
144
- <span> </span>
145
- {isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
146
- {subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
147
- {fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
148
- {shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
149
- {trailingPadding > 0 ? <span> </span> : null}
150
- </TextLine>
151
- </box>
152
- )
153
- })}
154
- <Filler rows={bottomPaddingRows} prefix="pad" />
155
- </>
156
- )
157
- useEffect(() => {
158
- setScrollTop((current) => commandPaletteScrollTop({ current, rowsLength: rows.length, listHeight, selectedRowIndex }))
159
- }, [listHeight, rows.length, selectedRowIndex])
160
- return (
161
- <SearchModalFrame
162
- left={offsetLeft}
163
- top={offsetTop}
164
- width={modalWidth}
165
- height={modalHeight}
166
- title="Commands"
167
- query={query}
168
- placeholder="Search"
169
- countText={countText}
170
- onBodyMouseScroll={handleMouseScroll}
171
- footer={<HintRow items={[{ key: "↑↓", label: "select" }, { key: "enter", label: "run" }, { key: "esc", label: "close" }]} />}
172
- >
173
- {content}
174
- </SearchModalFrame>
175
- )
176
- }