@kitlangton/ghui 0.1.21 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -4
- package/src/App.tsx +620 -792
- package/src/appCommands.ts +70 -16
- package/src/domain.ts +13 -0
- package/src/index.tsx +1 -7
- package/src/keyboard/opentuiAdapter.ts +43 -0
- package/src/keymap/all.ts +103 -0
- package/src/keymap/closeModal.ts +13 -0
- package/src/keymap/commandPalette.ts +16 -0
- package/src/keymap/commentModal.ts +73 -0
- package/src/keymap/commentThreadModal.ts +24 -0
- package/src/keymap/detailView.ts +30 -0
- package/src/keymap/diffView.ts +91 -0
- package/src/keymap/filterMode.ts +13 -0
- package/src/keymap/helpers.ts +24 -0
- package/src/keymap/labelModal.ts +16 -0
- package/src/keymap/listNav.ts +119 -0
- package/src/keymap/mergeModal.ts +23 -0
- package/src/keymap/openRepositoryModal.ts +13 -0
- package/src/keymap/themeModal.ts +49 -0
- package/src/mergeActions.ts +4 -1
- package/src/services/GitHubService.ts +42 -6
- package/src/services/MockGitHubService.ts +37 -2
- package/src/themeStore.ts +28 -11
- package/src/ui/CommandPalette.tsx +123 -63
- package/src/ui/DetailsPane.tsx +192 -54
- package/src/ui/FooterHints.tsx +9 -18
- package/src/ui/LoadingLogo.tsx +75 -0
- package/src/ui/PullRequestDiffPane.tsx +25 -18
- package/src/ui/PullRequestList.tsx +6 -2
- package/src/ui/comments.tsx +143 -0
- package/src/ui/diff.ts +198 -6
- package/src/ui/modals.tsx +4 -39
- package/src/ui/primitives.tsx +5 -1
- package/src/ui/singleLineInput.ts +2 -1
- package/src/ui/spinner.ts +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
export interface LabelModalCtx {
|
|
4
|
+
readonly closeModal: () => void
|
|
5
|
+
readonly toggleSelected: () => void
|
|
6
|
+
readonly moveSelection: (delta: -1 | 1) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const Label = context<LabelModalCtx>()
|
|
10
|
+
|
|
11
|
+
export const labelModalKeymap = Label(
|
|
12
|
+
{ id: "label-modal.close", title: "Close", keys: ["escape"], run: (s) => s.closeModal() },
|
|
13
|
+
{ id: "label-modal.toggle", title: "Toggle label", keys: ["return"], run: (s) => s.toggleSelected() },
|
|
14
|
+
{ id: "label-modal.up", title: "Up", keys: ["k", "up"], run: (s) => s.moveSelection(-1) },
|
|
15
|
+
{ id: "label-modal.down", title: "Down", keys: ["j", "down"], run: (s) => s.moveSelection(1) },
|
|
16
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
import { countedVerticalBindings } from "./helpers.ts"
|
|
3
|
+
|
|
4
|
+
export interface ListNavCtx {
|
|
5
|
+
readonly halfPage: number
|
|
6
|
+
readonly visibleCount: number
|
|
7
|
+
readonly hasFilter: boolean
|
|
8
|
+
readonly canScrollDetailPreview: boolean
|
|
9
|
+
readonly runCommandById: (id: string) => void
|
|
10
|
+
readonly switchQueueMode: (delta: 1 | -1) => void
|
|
11
|
+
readonly scrollDetailPreviewBy: (delta: number) => void
|
|
12
|
+
readonly scrollDetailPreviewTo: (line: number) => void
|
|
13
|
+
readonly clearFilter: () => void
|
|
14
|
+
readonly stepSelected: (delta: number) => void
|
|
15
|
+
readonly stepSelectedUp: (count?: number) => void
|
|
16
|
+
readonly stepSelectedDown: (count?: number) => void
|
|
17
|
+
readonly stepSelectedUpWrap: () => void
|
|
18
|
+
readonly stepSelectedDownWithLoadMore: () => void
|
|
19
|
+
readonly moveSelectedToPreviousGroup: () => void
|
|
20
|
+
readonly moveSelectedToNextGroup: () => void
|
|
21
|
+
readonly setSelected: (index: number) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const List = context<ListNavCtx>()
|
|
25
|
+
|
|
26
|
+
export const listNavKeymap = List(
|
|
27
|
+
// Single-key command shortcuts (delegate to existing AppCommand registry)
|
|
28
|
+
{ id: "list.filter", title: "Filter", keys: ["/"], run: (s) => s.runCommandById("filter.open") },
|
|
29
|
+
{ id: "list.refresh", title: "Refresh", keys: ["r"], run: (s) => s.runCommandById("pull.refresh") },
|
|
30
|
+
{ id: "list.theme", title: "Theme", keys: ["t"], run: (s) => s.runCommandById("theme.open") },
|
|
31
|
+
{ id: "list.diff", title: "Open diff", keys: ["d"], run: (s) => s.runCommandById("diff.open") },
|
|
32
|
+
{ id: "list.labels", title: "Labels", keys: ["l"], run: (s) => s.runCommandById("pull.labels") },
|
|
33
|
+
{ id: "list.merge", title: "Merge", keys: ["m", "shift+m"], run: (s) => s.runCommandById("pull.merge") },
|
|
34
|
+
{ id: "list.close-pr", title: "Close PR", keys: ["x"], run: (s) => s.runCommandById("pull.close") },
|
|
35
|
+
{ id: "list.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.runCommandById("pull.open-browser") },
|
|
36
|
+
{ id: "list.toggle-draft", title: "Toggle draft", keys: ["s", "shift+s"], run: (s) => s.runCommandById("pull.toggle-draft") },
|
|
37
|
+
{ id: "list.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.runCommandById("pull.copy-metadata") },
|
|
38
|
+
{ id: "list.detail.open", title: "Open details", keys: ["return"], run: (s) => s.runCommandById("detail.open") },
|
|
39
|
+
|
|
40
|
+
// Queue mode tabs
|
|
41
|
+
{ id: "list.next-tab", title: "Next view", keys: ["tab"], run: (s) => s.switchQueueMode(1) },
|
|
42
|
+
{ id: "list.prev-tab", title: "Previous view", keys: ["shift+tab"], run: (s) => s.switchQueueMode(-1) },
|
|
43
|
+
|
|
44
|
+
// Escape clears filter only when one is set
|
|
45
|
+
{
|
|
46
|
+
id: "list.clear-filter",
|
|
47
|
+
title: "Clear filter",
|
|
48
|
+
keys: ["escape"],
|
|
49
|
+
enabled: (s) => s.hasFilter ? true : "No filter to clear.",
|
|
50
|
+
run: (s) => s.clearFilter(),
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Wide-layout detail preview scroll
|
|
54
|
+
{
|
|
55
|
+
id: "list.preview.top",
|
|
56
|
+
title: "Detail preview top",
|
|
57
|
+
keys: ["home"],
|
|
58
|
+
enabled: (s) => s.canScrollDetailPreview ? true : "Detail preview not visible.",
|
|
59
|
+
run: (s) => s.scrollDetailPreviewTo(0),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "list.preview.bottom",
|
|
63
|
+
title: "Detail preview bottom",
|
|
64
|
+
keys: ["end"],
|
|
65
|
+
enabled: (s) => s.canScrollDetailPreview ? true : "Detail preview not visible.",
|
|
66
|
+
run: (s) => s.scrollDetailPreviewTo(Number.MAX_SAFE_INTEGER),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "list.preview.half-up",
|
|
70
|
+
title: "Detail preview ½ up",
|
|
71
|
+
keys: ["pageup"],
|
|
72
|
+
enabled: (s) => s.canScrollDetailPreview ? true : "Detail preview not visible.",
|
|
73
|
+
run: (s) => s.scrollDetailPreviewBy(-s.halfPage),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "list.preview.half-down",
|
|
77
|
+
title: "Detail preview ½ down",
|
|
78
|
+
keys: ["pagedown"],
|
|
79
|
+
enabled: (s) => s.canScrollDetailPreview ? true : "Detail preview not visible.",
|
|
80
|
+
run: (s) => s.scrollDetailPreviewBy(s.halfPage),
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// Group jumps
|
|
84
|
+
{
|
|
85
|
+
id: "list.group-prev",
|
|
86
|
+
title: "Previous group",
|
|
87
|
+
keys: ["[", "meta+up", "meta+k", "shift+k"],
|
|
88
|
+
run: (s) => s.moveSelectedToPreviousGroup(),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "list.group-next",
|
|
92
|
+
title: "Next group",
|
|
93
|
+
keys: ["]", "meta+down", "meta+j", "shift+j"],
|
|
94
|
+
run: (s) => s.moveSelectedToNextGroup(),
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
// Half-page steps
|
|
98
|
+
{ id: "list.half-up", title: "Half page up", keys: ["ctrl+u"], run: (s) => s.stepSelected(-s.halfPage) },
|
|
99
|
+
{ id: "list.half-down", title: "Half page down", keys: ["ctrl+d"], run: (s) => s.stepSelected(s.halfPage) },
|
|
100
|
+
|
|
101
|
+
// Vim count prefixes
|
|
102
|
+
...countedVerticalBindings<ListNavCtx>((s, delta) => {
|
|
103
|
+
if (delta < 0) s.stepSelectedUp(-delta)
|
|
104
|
+
else s.stepSelectedDown(delta)
|
|
105
|
+
}),
|
|
106
|
+
|
|
107
|
+
// Single-step (with wrap up, load-more on down)
|
|
108
|
+
{ id: "list.up", title: "Up", keys: ["up", "k"], run: (s) => s.stepSelectedUpWrap() },
|
|
109
|
+
{ id: "list.down", title: "Down", keys: ["down", "j"], run: (s) => s.stepSelectedDownWithLoadMore() },
|
|
110
|
+
|
|
111
|
+
// Top / bottom
|
|
112
|
+
{ id: "list.top", title: "Top", keys: ["g g"], run: (s) => s.setSelected(0) },
|
|
113
|
+
{
|
|
114
|
+
id: "list.bottom",
|
|
115
|
+
title: "Bottom",
|
|
116
|
+
keys: ["shift+g"],
|
|
117
|
+
run: (s) => s.setSelected(s.visibleCount === 0 ? 0 : s.visibleCount - 1),
|
|
118
|
+
},
|
|
119
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
export interface MergeModalCtx {
|
|
4
|
+
readonly availableActionCount: number
|
|
5
|
+
readonly closeModal: () => void
|
|
6
|
+
readonly confirmMerge: () => void
|
|
7
|
+
readonly moveSelection: (delta: -1 | 1) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const Merge = context<MergeModalCtx>()
|
|
11
|
+
|
|
12
|
+
export const mergeModalKeymap = Merge(
|
|
13
|
+
{ id: "merge-modal.cancel", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
|
|
14
|
+
{
|
|
15
|
+
id: "merge-modal.confirm",
|
|
16
|
+
title: "Merge pull request",
|
|
17
|
+
keys: ["return"],
|
|
18
|
+
enabled: (s) => s.availableActionCount > 0 ? true : "No merge actions available.",
|
|
19
|
+
run: (s) => s.confirmMerge(),
|
|
20
|
+
},
|
|
21
|
+
{ id: "merge-modal.up", title: "Up", keys: ["k", "up"], run: (s) => s.moveSelection(-1) },
|
|
22
|
+
{ id: "merge-modal.down", title: "Down", keys: ["j", "down"], run: (s) => s.moveSelection(1) },
|
|
23
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
export interface OpenRepositoryModalCtx {
|
|
4
|
+
readonly closeModal: () => void
|
|
5
|
+
readonly openFromInput: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const OpenRepo = context<OpenRepositoryModalCtx>()
|
|
9
|
+
|
|
10
|
+
export const openRepositoryModalKeymap = OpenRepo(
|
|
11
|
+
{ id: "open-repo.close", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
|
|
12
|
+
{ id: "open-repo.open", title: "Open repository", keys: ["return"], run: (s) => s.openFromInput() },
|
|
13
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|
+
)
|
package/src/mergeActions.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo } from "./domain.js"
|
|
1
|
+
import type { PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo, PullRequestState } from "./domain.js"
|
|
2
2
|
|
|
3
3
|
export interface MergeActionDefinition {
|
|
4
4
|
readonly action: PullRequestMergeAction
|
|
@@ -8,6 +8,7 @@ export interface MergeActionDefinition {
|
|
|
8
8
|
readonly pastTense: string
|
|
9
9
|
readonly danger?: boolean
|
|
10
10
|
readonly refreshOnSuccess?: boolean
|
|
11
|
+
readonly optimisticState?: PullRequestState
|
|
11
12
|
readonly optimisticAutoMergeEnabled?: boolean
|
|
12
13
|
readonly isAvailable: (info: PullRequestMergeInfo) => boolean
|
|
13
14
|
}
|
|
@@ -29,6 +30,7 @@ const mergeActionDefinitions = {
|
|
|
29
30
|
cliArgs: ["--squash", "--delete-branch"],
|
|
30
31
|
pastTense: "Merged",
|
|
31
32
|
refreshOnSuccess: true,
|
|
33
|
+
optimisticState: "merged",
|
|
32
34
|
isAvailable: isCleanlyMergeable,
|
|
33
35
|
},
|
|
34
36
|
auto: {
|
|
@@ -57,6 +59,7 @@ const mergeActionDefinitions = {
|
|
|
57
59
|
pastTense: "Admin merged",
|
|
58
60
|
danger: true,
|
|
59
61
|
refreshOnSuccess: true,
|
|
62
|
+
optimisticState: "merged",
|
|
60
63
|
isAvailable: (info) => info.state === "open" && !info.isDraft && info.mergeable !== "conflicting",
|
|
61
64
|
},
|
|
62
65
|
} as const satisfies Record<PullRequestMergeAction, MergeActionDefinition>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Context, Effect, Layer, Schema } from "effect"
|
|
2
2
|
import { config } from "../config.js"
|
|
3
|
-
import { DiffCommentSide, pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type ListPullRequestPageInput, type Mergeable, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestPage, type PullRequestQueueMode, type PullRequestReviewComment, type ReviewStatus } from "../domain.js"
|
|
3
|
+
import { DiffCommentSide, pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type ListPullRequestPageInput, type Mergeable, type PullRequestConversationItem, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestPage, type PullRequestQueueMode, type PullRequestReviewComment, type ReviewStatus } from "../domain.js"
|
|
4
4
|
import { getMergeActionDefinition } from "../mergeActions.js"
|
|
5
5
|
import { CommandError, CommandRunner, type JsonParseError } from "./CommandRunner.js"
|
|
6
6
|
|
|
@@ -429,18 +429,22 @@ const repositoryParts = (repository: string) => {
|
|
|
429
429
|
return owner && name ? { owner, name } : null
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
+
const rawCommentFields = (comment: RawPullRequestComment, fallbackId: string) => ({
|
|
433
|
+
id: String(comment.id ?? comment.node_id ?? fallbackId),
|
|
434
|
+
author: comment.user?.login ?? "unknown",
|
|
435
|
+
body: comment.body ?? "",
|
|
436
|
+
createdAt: comment.created_at ? new Date(comment.created_at) : null,
|
|
437
|
+
url: comment.html_url ?? comment.url ?? null,
|
|
438
|
+
})
|
|
439
|
+
|
|
432
440
|
const parsePullRequestComment = (comment: RawPullRequestComment): PullRequestReviewComment | null => {
|
|
433
441
|
const line = comment.line ?? comment.original_line
|
|
434
442
|
if (!comment.path || !line || (comment.side !== "LEFT" && comment.side !== "RIGHT")) return null
|
|
435
443
|
return {
|
|
436
|
-
|
|
444
|
+
...rawCommentFields(comment, `${comment.path}:${comment.side}:${line}:${comment.created_at ?? ""}:${comment.body ?? ""}`),
|
|
437
445
|
path: comment.path,
|
|
438
446
|
line,
|
|
439
447
|
side: comment.side,
|
|
440
|
-
author: comment.user?.login ?? "unknown",
|
|
441
|
-
body: comment.body ?? "",
|
|
442
|
-
createdAt: comment.created_at ? new Date(comment.created_at) : null,
|
|
443
|
-
url: comment.html_url ?? comment.url ?? null,
|
|
444
448
|
}
|
|
445
449
|
}
|
|
446
450
|
|
|
@@ -451,6 +455,24 @@ const parsePullRequestComments = (response: Schema.Schema.Type<typeof CommentsRe
|
|
|
451
455
|
})
|
|
452
456
|
}
|
|
453
457
|
|
|
458
|
+
const parseIssueComment = (comment: RawPullRequestComment): PullRequestConversationItem => ({
|
|
459
|
+
_tag: "comment",
|
|
460
|
+
...rawCommentFields(comment, `${comment.created_at ?? ""}:${comment.body ?? ""}`),
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
const reviewCommentConversationItem = (comment: PullRequestReviewComment): PullRequestConversationItem => ({
|
|
464
|
+
_tag: "review-comment",
|
|
465
|
+
...comment,
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
const conversationItemTime = (item: PullRequestConversationItem) => item.createdAt?.getTime() ?? Number.MAX_SAFE_INTEGER
|
|
469
|
+
|
|
470
|
+
const sortConversationItems = (items: readonly PullRequestConversationItem[]) =>
|
|
471
|
+
[...items].sort((left, right) => conversationItemTime(left) - conversationItemTime(right) || left.id.localeCompare(right.id))
|
|
472
|
+
|
|
473
|
+
const parseIssueComments = (response: Schema.Schema.Type<typeof CommentsResponseSchema>): readonly PullRequestConversationItem[] =>
|
|
474
|
+
flattenSlurpedPages(response).map(parseIssueComment)
|
|
475
|
+
|
|
454
476
|
const flattenSlurpedPages = <Item>(response: readonly Item[] | readonly (readonly Item[])[]): readonly Item[] =>
|
|
455
477
|
Array.isArray(response[0]) ? (response as readonly (readonly Item[])[]).flat() : response as readonly Item[]
|
|
456
478
|
|
|
@@ -508,6 +530,7 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
508
530
|
readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
|
|
509
531
|
readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, GitHubError>
|
|
510
532
|
readonly listPullRequestComments: (repository: string, number: number) => Effect.Effect<readonly PullRequestReviewComment[], GitHubError>
|
|
533
|
+
readonly listPullRequestConversation: (repository: string, number: number) => Effect.Effect<readonly PullRequestConversationItem[], GitHubError>
|
|
511
534
|
readonly getPullRequestMergeInfo: (repository: string, number: number) => Effect.Effect<PullRequestMergeInfo, GitHubError>
|
|
512
535
|
readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
|
|
513
536
|
readonly closePullRequest: (repository: string, number: number) => Effect.Effect<void, CommandError>
|
|
@@ -628,6 +651,17 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
628
651
|
"api", "--paginate", "--slurp", `repos/${repository}/pulls/${number}/comments`,
|
|
629
652
|
]).pipe(Effect.map(parsePullRequestComments))
|
|
630
653
|
|
|
654
|
+
const listPullRequestConversation = Effect.fn("GitHubService.listPullRequestConversation")(function*(repository: string, number: number) {
|
|
655
|
+
const [issueComments, reviewComments] = yield* Effect.all([
|
|
656
|
+
ghJson("listPullRequestIssueComments", CommentsResponseSchema, [
|
|
657
|
+
"api", "--paginate", "--slurp", `repos/${repository}/issues/${number}/comments`,
|
|
658
|
+
]).pipe(Effect.map(parseIssueComments)),
|
|
659
|
+
listPullRequestComments(repository, number).pipe(Effect.map((comments) => comments.map(reviewCommentConversationItem))),
|
|
660
|
+
], { concurrency: "unbounded" })
|
|
661
|
+
|
|
662
|
+
return sortConversationItems([...issueComments, ...reviewComments])
|
|
663
|
+
})
|
|
664
|
+
|
|
631
665
|
const getPullRequestMergeInfo = Effect.fn("GitHubService.getPullRequestMergeInfo")(function*(repository: string, number: number) {
|
|
632
666
|
const info = yield* command.runSchema(MergeInfoResponseSchema, "gh", [
|
|
633
667
|
"pr", "view", String(number), "--repo", repository,
|
|
@@ -663,6 +697,7 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
663
697
|
"-f", `path=${input.path}`,
|
|
664
698
|
"-F", `line=${input.line}`,
|
|
665
699
|
"-f", `side=${input.side}`,
|
|
700
|
+
...(input.startLine === undefined ? [] : ["-F", `start_line=${input.startLine}`, "-f", `start_side=${input.startSide ?? input.side}`]),
|
|
666
701
|
])
|
|
667
702
|
return parsePullRequestComment(response) ?? fallbackCreatedComment(input)
|
|
668
703
|
})
|
|
@@ -689,6 +724,7 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
689
724
|
getAuthenticatedUser,
|
|
690
725
|
getPullRequestDiff,
|
|
691
726
|
listPullRequestComments,
|
|
727
|
+
listPullRequestConversation,
|
|
692
728
|
getPullRequestMergeInfo,
|
|
693
729
|
mergePullRequest,
|
|
694
730
|
closePullRequest,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Layer } from "effect"
|
|
2
|
-
import type { CheckItem, CreatePullRequestCommentInput, Mergeable, PullRequestItem, PullRequestLabel, PullRequestMergeInfo, PullRequestPage, PullRequestQueueMode, PullRequestReviewComment, ReviewStatus } from "../domain.js"
|
|
2
|
+
import type { CheckItem, CreatePullRequestCommentInput, Mergeable, PullRequestConversationItem, PullRequestItem, PullRequestLabel, PullRequestMergeInfo, PullRequestPage, PullRequestQueueMode, PullRequestReviewComment, ReviewStatus } from "../domain.js"
|
|
3
3
|
import { GitHubService } from "./GitHubService.js"
|
|
4
4
|
|
|
5
5
|
export interface MockOptions {
|
|
@@ -87,6 +87,19 @@ const pageItems = (source: readonly PullRequestItem[], cursor: string | null, pa
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
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
|
+
|
|
90
103
|
export const MockGitHubService = {
|
|
91
104
|
layer: (options: MockOptions) => {
|
|
92
105
|
const items = buildMockPullRequests(options)
|
|
@@ -101,6 +114,27 @@ export const MockGitHubService = {
|
|
|
101
114
|
detailLoaded: false,
|
|
102
115
|
} satisfies PullRequestItem))
|
|
103
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
|
+
]
|
|
104
138
|
|
|
105
139
|
return Layer.succeed(
|
|
106
140
|
GitHubService,
|
|
@@ -110,8 +144,9 @@ export const MockGitHubService = {
|
|
|
110
144
|
listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, items)),
|
|
111
145
|
getPullRequestDetails: (repository, number) => Effect.succeed(findPullRequest(repository, number)),
|
|
112
146
|
getAuthenticatedUser: () => Effect.succeed(username),
|
|
113
|
-
getPullRequestDiff: (_repo, _number) => Effect.succeed(
|
|
147
|
+
getPullRequestDiff: (_repo, _number) => Effect.succeed(mockDiff),
|
|
114
148
|
listPullRequestComments: (_repo, _number) => Effect.succeed([] as readonly PullRequestReviewComment[]),
|
|
149
|
+
listPullRequestConversation: (repository, number) => Effect.succeed(conversationItems(repository, number)),
|
|
115
150
|
getPullRequestMergeInfo: (repository, number) => Effect.succeed({
|
|
116
151
|
repository,
|
|
117
152
|
number,
|
package/src/themeStore.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import { dirname, join } from "node:path"
|
|
4
|
-
import { Effect } from "effect"
|
|
4
|
+
import { Effect, Schema } from "effect"
|
|
5
5
|
import { isThemeId, type ThemeId } from "./ui/colors.js"
|
|
6
|
+
import { DiffWhitespaceMode } from "./ui/diff.js"
|
|
6
7
|
|
|
7
8
|
interface StoredConfig {
|
|
8
9
|
readonly theme?: unknown
|
|
10
|
+
readonly diffWhitespaceMode?: unknown
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
const configDirectory = () => {
|
|
@@ -22,22 +24,37 @@ const parseConfig = (text: string): StoredConfig => {
|
|
|
22
24
|
return value && typeof value === "object" ? value : {}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
const readStoredConfig = async () => {
|
|
26
28
|
const file = Bun.file(configPath())
|
|
27
|
-
|
|
29
|
+
return await file.exists() ? parseConfig(await file.text()) : {}
|
|
30
|
+
}
|
|
28
31
|
|
|
29
|
-
|
|
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()
|
|
30
40
|
return isThemeId(config.theme) ? config.theme : "ghui"
|
|
31
41
|
}), () => Effect.succeed("ghui" satisfies ThemeId))
|
|
32
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
|
+
|
|
33
48
|
export const saveStoredThemeId = (theme: ThemeId): Effect.Effect<void> => Effect.tryPromise(async () => {
|
|
34
|
-
const
|
|
35
|
-
const file = Bun.file(path)
|
|
36
|
-
const config = await file.exists()
|
|
37
|
-
? parseConfig(await file.text())
|
|
38
|
-
: {}
|
|
49
|
+
const config = await readStoredConfig()
|
|
39
50
|
if (config.theme === theme) return
|
|
40
51
|
|
|
41
|
-
await
|
|
42
|
-
|
|
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 })
|
|
43
60
|
})
|