@kitlangton/ghui 0.2.0 → 0.3.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/src/domain.ts CHANGED
@@ -16,10 +16,10 @@ export const pullRequestQueueLabels = {
16
16
  mentioned: "mentioned",
17
17
  } as const satisfies Record<PullRequestQueueMode, string>
18
18
 
19
- export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, author: string, repository: string | null) => {
19
+ export const pullRequestQueueSearchQualifier = (mode: PullRequestQueueMode, repository: string | null) => {
20
20
  const qualifiers = {
21
- repository: repository ? `repo:${repository}` : `author:${author}`,
22
- authored: `author:${author}`,
21
+ repository: repository ? `repo:${repository}` : "author:@me",
22
+ authored: "author:@me",
23
23
  review: "review-requested:@me",
24
24
  assigned: "assignee:@me",
25
25
  mentioned: "mentions:@me",
@@ -44,6 +44,9 @@ export type DiffCommentSide = Schema.Schema.Type<typeof DiffCommentSide>
44
44
 
45
45
  export type PullRequestMergeAction = "squash" | "auto" | "admin" | "disable-auto"
46
46
 
47
+ export const pullRequestReviewEvents = ["COMMENT", "APPROVE", "REQUEST_CHANGES"] as const
48
+ export type PullRequestReviewEvent = (typeof pullRequestReviewEvents)[number]
49
+
47
50
  export interface CheckItem {
48
51
  readonly name: string
49
52
  readonly status: CheckRunStatus
@@ -67,6 +70,13 @@ export interface CreatePullRequestCommentInput {
67
70
  readonly body: string
68
71
  }
69
72
 
73
+ export interface SubmitPullRequestReviewInput {
74
+ readonly repository: string
75
+ readonly number: number
76
+ readonly event: PullRequestReviewEvent
77
+ readonly body: string
78
+ }
79
+
70
80
  export interface PullRequestReviewComment {
71
81
  readonly id: string
72
82
  readonly path: string
package/src/keymap/all.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { context } from "@ghui/keymap"
2
+ import { changedFilesModalKeymap, type ChangedFilesModalCtx } from "./changedFilesModal.ts"
2
3
  import { closeModalKeymap, type CloseModalCtx } from "./closeModal.ts"
3
4
  import { commandPaletteKeymap, type CommandPaletteCtx } from "./commandPalette.ts"
4
5
  import { commentModalKeymap, type CommentModalCtx } from "./commentModal.ts"
@@ -10,6 +11,7 @@ import { labelModalKeymap, type LabelModalCtx } from "./labelModal.ts"
10
11
  import { listNavKeymap, type ListNavCtx } from "./listNav.ts"
11
12
  import { mergeModalKeymap, type MergeModalCtx } from "./mergeModal.ts"
12
13
  import { openRepositoryModalKeymap, type OpenRepositoryModalCtx } from "./openRepositoryModal.ts"
14
+ import { submitReviewModalKeymap, type SubmitReviewModalCtx } from "./submitReviewModal.ts"
13
15
  import { themeModalKeymap, type ThemeModalCtx } from "./themeModal.ts"
14
16
 
15
17
  export interface AppCtx {
@@ -17,6 +19,8 @@ export interface AppCtx {
17
19
  readonly closeModalActive: boolean
18
20
  readonly mergeModalActive: boolean
19
21
  readonly commentThreadModalActive: boolean
22
+ readonly changedFilesModalActive: boolean
23
+ readonly submitReviewModalActive: boolean
20
24
  readonly labelModalActive: boolean
21
25
  readonly themeModalActive: boolean
22
26
  readonly openRepositoryModalActive: boolean
@@ -34,6 +38,8 @@ export interface AppCtx {
34
38
  readonly closeModal: CloseModalCtx
35
39
  readonly mergeModal: MergeModalCtx
36
40
  readonly commentThreadModal: CommentThreadModalCtx
41
+ readonly changedFilesModal: ChangedFilesModalCtx
42
+ readonly submitReviewModal: SubmitReviewModalCtx
37
43
  readonly labelModal: LabelModalCtx
38
44
  readonly themeModal: ThemeModalCtx
39
45
  readonly openRepositoryModal: OpenRepositoryModalCtx
@@ -51,15 +57,20 @@ export interface AppCtx {
51
57
 
52
58
  const App = context<AppCtx>()
53
59
 
60
+ const modalActive = (a: AppCtx): boolean =>
61
+ a.closeModalActive
62
+ || a.mergeModalActive
63
+ || a.commentThreadModalActive
64
+ || a.changedFilesModalActive
65
+ || a.submitReviewModalActive
66
+ || a.labelModalActive
67
+ || a.themeModalActive
68
+ || a.openRepositoryModalActive
69
+ || a.commentModalActive
70
+ || a.commandPaletteActive
71
+
54
72
  const inListMode = (a: AppCtx): boolean =>
55
- !a.closeModalActive
56
- && !a.mergeModalActive
57
- && !a.commentThreadModalActive
58
- && !a.labelModalActive
59
- && !a.themeModalActive
60
- && !a.openRepositoryModalActive
61
- && !a.commentModalActive
62
- && !a.commandPaletteActive
73
+ !modalActive(a)
63
74
  && !a.filterMode
64
75
  && !a.diffFullView
65
76
  && !a.detailFullView
@@ -87,6 +98,8 @@ export const appKeymap = App(
87
98
  closeModalKeymap.scope((a) => a.closeModalActive && a.closeModal),
88
99
  mergeModalKeymap.scope((a) => a.mergeModalActive && a.mergeModal),
89
100
  commentThreadModalKeymap.scope((a) => a.commentThreadModalActive && a.commentThreadModal),
101
+ changedFilesModalKeymap.scope((a) => a.changedFilesModalActive && a.changedFilesModal),
102
+ submitReviewModalKeymap.scope((a) => a.submitReviewModalActive && a.submitReviewModal),
90
103
  labelModalKeymap.scope((a) => a.labelModalActive && a.labelModal),
91
104
  themeModalKeymap.scope((a) => a.themeModalActive && a.themeModal),
92
105
  openRepositoryModalKeymap.scope((a) => a.openRepositoryModalActive && a.openRepositoryModal),
@@ -95,8 +108,8 @@ export const appKeymap = App(
95
108
  filterModeKeymap.scope((a) => a.filterMode && a.filterModeCtx),
96
109
 
97
110
  // Full-view layers (only when no modal is on top)
98
- diffViewKeymap.scope((a) => a.diffFullView && !a.commandPaletteActive && a.diff),
99
- detailViewKeymap.scope((a) => a.detailFullView && !a.commandPaletteActive && a.detail),
111
+ diffViewKeymap.scope((a) => a.diffFullView && !modalActive(a) && a.diff),
112
+ detailViewKeymap.scope((a) => a.detailFullView && !modalActive(a) && a.detail),
100
113
 
101
114
  // PR list nav
102
115
  listNavKeymap.scope((a) => inListMode(a) && a.listNav),
@@ -0,0 +1,23 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface ChangedFilesModalCtx {
4
+ readonly hasResults: boolean
5
+ readonly closeModal: () => void
6
+ readonly selectFile: () => void
7
+ readonly moveSelection: (delta: -1 | 1) => void
8
+ }
9
+
10
+ const ChangedFiles = context<ChangedFilesModalCtx>()
11
+
12
+ export const changedFilesModalKeymap = ChangedFiles(
13
+ { id: "changed-files.close", title: "Close", keys: ["escape"], run: (s) => s.closeModal() },
14
+ {
15
+ id: "changed-files.select",
16
+ title: "Jump to file",
17
+ keys: ["return"],
18
+ enabled: (s) => s.hasResults ? true : "No matching files.",
19
+ run: (s) => s.selectFile(),
20
+ },
21
+ { id: "changed-files.up", title: "Up", keys: ["k", "up", "ctrl+p", "ctrl+k"], run: (s) => s.moveSelection(-1) },
22
+ { id: "changed-files.down", title: "Down", keys: ["j", "down", "ctrl+n", "ctrl+j"], run: (s) => s.moveSelection(1) },
23
+ )
@@ -11,6 +11,6 @@ const Palette = context<CommandPaletteCtx>()
11
11
  export const commandPaletteKeymap = Palette(
12
12
  { id: "palette.close", title: "Close palette", keys: ["escape", "ctrl+c"], run: (s) => s.closeModal() },
13
13
  { id: "palette.run", title: "Run command", keys: ["return"], run: (s) => s.runSelected() },
14
- { id: "palette.up", title: "Up", keys: ["up"], run: (s) => s.moveSelection(-1) },
15
- { id: "palette.down", title: "Down", keys: ["down"], run: (s) => s.moveSelection(1) },
14
+ { id: "palette.up", title: "Up", keys: ["up", "ctrl+p", "ctrl+k"], run: (s) => s.moveSelection(-1) },
15
+ { id: "palette.down", title: "Down", keys: ["down", "ctrl+n", "ctrl+j"], run: (s) => s.moveSelection(1) },
16
16
  )
@@ -11,7 +11,7 @@ const Thread = context<CommentThreadModalCtx>()
11
11
 
12
12
  export const commentThreadModalKeymap = Thread(
13
13
  { id: "comment-thread.close", title: "Close", keys: ["escape"], run: (s) => s.closeModal() },
14
- { id: "comment-thread.reply", title: "Reply", keys: ["return", "a", "c"], run: (s) => s.openInlineComment() },
14
+ { id: "comment-thread.reply", title: "Reply", keys: ["return"], run: (s) => s.openInlineComment() },
15
15
  { id: "comment-thread.up", title: "Up", keys: ["k", "up"], run: (s) => s.scrollBy(-1) },
16
16
  { id: "comment-thread.down", title: "Down", keys: ["j", "down"], run: (s) => s.scrollBy(1) },
17
17
  { id: "comment-thread.half-up", title: "Half page up", keys: ["pageup", "ctrl+u"], run: (s) => s.scrollBy(-s.halfPage) },
@@ -8,7 +8,6 @@ export interface DiffViewCtx {
8
8
  readonly halfPage: number
9
9
  readonly handleEscape: () => void // closes diff, or clears comment range if active
10
10
  readonly openSelectedComment: () => void
11
- readonly addComment: () => void
12
11
  readonly toggleRange: () => void
13
12
  readonly toggleView: () => void
14
13
  readonly toggleWrap: () => void
@@ -19,6 +18,8 @@ export interface DiffViewCtx {
19
18
  readonly moveAnchorToBoundary: (boundary: "first" | "last") => void
20
19
  readonly alignAnchor: (align: DiffAlign) => void
21
20
  readonly selectSide: (side: DiffSide) => void
21
+ readonly openChangedFiles: () => void
22
+ readonly openSubmitReview: () => void
22
23
  readonly nextFile: () => void
23
24
  readonly previousFile: () => void
24
25
  readonly openInBrowser: () => void
@@ -29,7 +30,6 @@ const Diff = context<DiffViewCtx>()
29
30
  export const diffViewKeymap = Diff(
30
31
  { id: "diff.escape", title: "Close diff / clear range", keys: ["escape"], run: (s) => s.handleEscape() },
31
32
  { id: "diff.open-comment", title: "Open / add comment", keys: ["return"], run: (s) => s.openSelectedComment() },
32
- { id: "diff.add-comment", title: "Add comment on line", keys: ["a"], run: (s) => s.addComment() },
33
33
  { id: "diff.toggle-range", title: "Toggle comment range", keys: ["v"], run: (s) => s.toggleRange() },
34
34
  { id: "diff.toggle-view", title: "Toggle split/unified", keys: ["shift+v"], run: (s) => s.toggleView() },
35
35
  { id: "diff.toggle-wrap", title: "Toggle wrap", keys: ["w"], run: (s) => s.toggleWrap() },
@@ -77,8 +77,10 @@ export const diffViewKeymap = Diff(
77
77
  { id: "diff.side-right", title: "New side", keys: ["right", "l"], run: (s) => s.selectSide("RIGHT") },
78
78
 
79
79
  // File nav
80
+ { id: "diff.changed-files", title: "Changed files", keys: ["f"], run: (s) => s.openChangedFiles() },
80
81
  { id: "diff.next-file", title: "Next file", keys: ["]"], run: (s) => s.nextFile() },
81
82
  { id: "diff.previous-file", title: "Previous file", keys: ["["], run: (s) => s.previousFile() },
83
+ { id: "diff.submit-review", title: "Submit review", keys: ["shift+r"], run: (s) => s.openSubmitReview() },
82
84
 
83
85
  // Boundary jumps + align
84
86
  { id: "diff.first", title: "First comment", keys: ["g g"], run: (s) => s.moveAnchorToBoundary("first") },
@@ -11,6 +11,6 @@ const Label = context<LabelModalCtx>()
11
11
  export const labelModalKeymap = Label(
12
12
  { id: "label-modal.close", title: "Close", keys: ["escape"], run: (s) => s.closeModal() },
13
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) },
14
+ { id: "label-modal.up", title: "Up", keys: ["k", "up", "ctrl+p", "ctrl+k"], run: (s) => s.moveSelection(-1) },
15
+ { id: "label-modal.down", title: "Down", keys: ["j", "down", "ctrl+n", "ctrl+j"], run: (s) => s.moveSelection(1) },
16
16
  )
@@ -0,0 +1,48 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface SubmitReviewModalCtx {
4
+ readonly closeModal: () => void
5
+ readonly submit: () => void
6
+ readonly insertNewline: () => void
7
+ readonly moveActionSelection: (delta: -1 | 1) => void
8
+ readonly moveLeft: () => void
9
+ readonly moveRight: () => void
10
+ readonly moveUp: () => void
11
+ readonly moveDown: () => void
12
+ readonly moveLineStart: () => void
13
+ readonly moveLineEnd: () => void
14
+ readonly moveWordBackward: () => void
15
+ readonly moveWordForward: () => void
16
+ readonly backspace: () => void
17
+ readonly deleteForward: () => void
18
+ readonly deleteWordBackward: () => void
19
+ readonly deleteWordForward: () => void
20
+ readonly deleteToLineStart: () => void
21
+ readonly deleteToLineEnd: () => void
22
+ }
23
+
24
+ const SubmitReview = context<SubmitReviewModalCtx>()
25
+
26
+ export const submitReviewModalKeymap = SubmitReview(
27
+ { id: "submit-review.escape", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
28
+ { id: "submit-review.submit", title: "Submit", keys: ["ctrl+s", "return"], run: (s) => s.submit() },
29
+ { id: "submit-review.newline", title: "Insert newline", keys: ["shift+return"], run: (s) => s.insertNewline() },
30
+ { id: "submit-review.next-action", title: "Next action", keys: ["tab"], run: (s) => s.moveActionSelection(1) },
31
+ { id: "submit-review.previous-action", title: "Previous action", keys: ["shift+tab"], run: (s) => s.moveActionSelection(-1) },
32
+
33
+ { id: "submit-review.move-left", title: "Cursor left", keys: ["left", "ctrl+b"], run: (s) => s.moveLeft() },
34
+ { id: "submit-review.move-right", title: "Cursor right", keys: ["right", "ctrl+f"], run: (s) => s.moveRight() },
35
+ { id: "submit-review.move-up", title: "Cursor up", keys: ["up"], run: (s) => s.moveUp() },
36
+ { id: "submit-review.move-down", title: "Cursor down", keys: ["down"], run: (s) => s.moveDown() },
37
+ { id: "submit-review.line-start", title: "Line start", keys: ["home", "ctrl+a"], run: (s) => s.moveLineStart() },
38
+ { id: "submit-review.line-end", title: "Line end", keys: ["end", "ctrl+e"], run: (s) => s.moveLineEnd() },
39
+ { id: "submit-review.word-back", title: "Word backward", keys: ["meta+b", "meta+left"], run: (s) => s.moveWordBackward() },
40
+ { id: "submit-review.word-forward", title: "Word forward", keys: ["meta+f", "meta+right"], run: (s) => s.moveWordForward() },
41
+
42
+ { id: "submit-review.backspace", title: "Backspace", keys: ["backspace"], run: (s) => s.backspace() },
43
+ { id: "submit-review.delete", title: "Delete", keys: ["delete", "ctrl+d"], run: (s) => s.deleteForward() },
44
+ { id: "submit-review.delete-word-back", title: "Delete word backward", keys: ["ctrl+w", "meta+backspace"], run: (s) => s.deleteWordBackward() },
45
+ { id: "submit-review.delete-word-forward", title: "Delete word forward", keys: ["meta+delete"], run: (s) => s.deleteWordForward() },
46
+ { id: "submit-review.delete-to-line-start", title: "Delete to line start", keys: ["ctrl+u"], run: (s) => s.deleteToLineStart() },
47
+ { id: "submit-review.delete-to-line-end", title: "Delete to line end", keys: ["ctrl+k"], run: (s) => s.deleteToLineEnd() },
48
+ )
@@ -30,8 +30,8 @@ export const themeModalKeymap = Theme(
30
30
  enabled: (s) => s.filterMode && !s.hasFilteredResults ? "No matching themes." : true,
31
31
  run: (s) => s.confirmSelection(),
32
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) },
33
+ { id: "theme-modal.up-arrow", title: "Up", keys: ["up", "ctrl+p", "ctrl+k"], run: (s) => s.moveSelection(-1) },
34
+ { id: "theme-modal.down-arrow", title: "Down", keys: ["down", "ctrl+n", "ctrl+j"], run: (s) => s.moveSelection(1) },
35
35
  {
36
36
  id: "theme-modal.up-letter",
37
37
  title: "Up",
@@ -4,9 +4,7 @@ export type PullRequestView =
4
4
  | { readonly _tag: "Repository"; readonly repository: string }
5
5
  | { readonly _tag: "Queue"; readonly mode: PullRequestUserQueueMode; readonly repository: string | null }
6
6
 
7
- export const initialPullRequestView = (repository: string | null): PullRequestView => repository
8
- ? { _tag: "Repository", repository }
9
- : { _tag: "Queue", mode: "authored", repository: null }
7
+ export const initialPullRequestView = (): PullRequestView => ({ _tag: "Queue", mode: "authored", repository: null })
10
8
 
11
9
  export const viewMode = (view: PullRequestView): PullRequestQueueMode => view._tag === "Repository" ? "repository" : view.mode
12
10
 
@@ -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 PullRequestConversationItem, 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, type SubmitPullRequestReviewInput } from "../domain.js"
4
4
  import { getMergeActionDefinition } from "../mergeActions.js"
5
5
  import { CommandError, CommandRunner, type JsonParseError } from "./CommandRunner.js"
6
6
 
@@ -413,9 +413,9 @@ const parsePullRequest = (item: RawPullRequestNode): PullRequestItem => {
413
413
  }
414
414
  }
415
415
 
416
- const searchQuery = (mode: PullRequestQueueMode, author: string, repository: string | null) => {
416
+ const searchQuery = (mode: PullRequestQueueMode, repository: string | null) => {
417
417
  const sort = mode === "repository" ? "sort:updated-desc" : "sort:created-desc"
418
- return `${pullRequestQueueSearchQualifier(mode, author, repository)} is:pr is:open ${sort}`
418
+ return `${pullRequestQueueSearchQualifier(mode, repository)} is:pr is:open ${sort}`
419
419
  }
420
420
 
421
421
  const pullRequestPage = <Item>(connection: PullRequestConnection<Item>, parse: (node: Item) => PullRequestItem): PullRequestPage => ({
@@ -506,7 +506,7 @@ const fallbackCreatedComment = (input: CreatePullRequestCommentInput): PullReque
506
506
  path: input.path,
507
507
  line: input.line,
508
508
  side: input.side,
509
- author: config.author.replace(/^@/, "") || "you",
509
+ author: "you",
510
510
  body: input.body,
511
511
  createdAt: new Date(),
512
512
  url: null,
@@ -522,6 +522,12 @@ const MERGEABLE_BY_RAW: Record<string, Mergeable> = {
522
522
  const normalizeMergeable = (value: string): Mergeable =>
523
523
  MERGEABLE_BY_RAW[value] ?? "unknown"
524
524
 
525
+ const REVIEW_EVENT_CLI_FLAG = {
526
+ COMMENT: "--comment",
527
+ APPROVE: "--approve",
528
+ REQUEST_CHANGES: "--request-changes",
529
+ } as const satisfies Record<SubmitPullRequestReviewInput["event"], string>
530
+
525
531
  export class GitHubService extends Context.Service<GitHubService, {
526
532
  readonly listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null) => Effect.Effect<readonly PullRequestItem[], GitHubError>
527
533
  readonly listOpenPullRequestPage: (input: ListPullRequestPageInput) => Effect.Effect<PullRequestPage, GitHubError>
@@ -535,6 +541,7 @@ export class GitHubService extends Context.Service<GitHubService, {
535
541
  readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
536
542
  readonly closePullRequest: (repository: string, number: number) => Effect.Effect<void, CommandError>
537
543
  readonly createPullRequestComment: (input: CreatePullRequestCommentInput) => Effect.Effect<PullRequestReviewComment, GitHubError>
544
+ readonly submitPullRequestReview: (input: SubmitPullRequestReviewInput) => Effect.Effect<void, CommandError>
538
545
  readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
539
546
  readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
540
547
  readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
@@ -557,7 +564,7 @@ export class GitHubService extends Context.Service<GitHubService, {
557
564
  const response: SearchResponse<Item["Type"]> = yield* command.runSchema(responseSchema, "gh", [
558
565
  "api", "graphql",
559
566
  "-f", `query=${query}`,
560
- "-F", `searchQuery=${searchQuery(input.mode, config.author, input.repository)}`,
567
+ "-F", `searchQuery=${searchQuery(input.mode, input.repository)}`,
561
568
  "-F", `first=${input.pageSize}`,
562
569
  ...(input.cursor ? ["-F", `after=${input.cursor}`] : []),
563
570
  ])
@@ -702,6 +709,13 @@ export class GitHubService extends Context.Service<GitHubService, {
702
709
  return parsePullRequestComment(response) ?? fallbackCreatedComment(input)
703
710
  })
704
711
 
712
+ const submitPullRequestReview = (input: SubmitPullRequestReviewInput) =>
713
+ ghVoid("submitPullRequestReview", [
714
+ "pr", "review", String(input.number), "--repo", input.repository,
715
+ REVIEW_EVENT_CLI_FLAG[input.event],
716
+ "--body", input.body,
717
+ ])
718
+
705
719
  const toggleDraftStatus = (repository: string, number: number, isDraft: boolean) =>
706
720
  ghVoid("toggleDraftStatus", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
707
721
 
@@ -729,6 +743,7 @@ export class GitHubService extends Context.Service<GitHubService, {
729
743
  mergePullRequest,
730
744
  closePullRequest,
731
745
  createPullRequestComment,
746
+ submitPullRequestReview,
732
747
  toggleDraftStatus,
733
748
  listRepoLabels,
734
749
  addPullRequestLabel,
@@ -171,6 +171,7 @@ export const MockGitHubService = {
171
171
  createdAt: new Date(),
172
172
  url: null,
173
173
  } satisfies PullRequestReviewComment),
174
+ submitPullRequestReview: () => Effect.void,
174
175
  toggleDraftStatus: () => Effect.void,
175
176
  listRepoLabels: () => Effect.succeed([]),
176
177
  addPullRequestLabel: () => Effect.void,
@@ -4,7 +4,7 @@ import type { AppCommand } from "../commands.js"
4
4
  import { clampCommandIndex } from "../commands.js"
5
5
  import { colors } from "./colors.js"
6
6
  import { scrollTopForVisibleLine } from "./diff.js"
7
- import { centerCell, Divider, Filler, fitCell, HintRow, ModalFrame, PaddedRow, PlainLine, standardModalDims, TextLine, trimCell } from "./primitives.js"
7
+ import { centerCell, Filler, fitCell, HintRow, PlainLine, searchModalDims, SearchModalFrame, TextLine, trimCell } from "./primitives.js"
8
8
 
9
9
  const scopeLabels = {
10
10
  Global: "App",
@@ -79,8 +79,7 @@ export const CommandPalette = ({
79
79
  onSelectCommandIndex: (index: number) => void
80
80
  onRunCommand: (command: AppCommand) => void
81
81
  }) => {
82
- const { innerWidth, contentWidth, rowWidth } = standardModalDims(modalWidth, modalHeight)
83
- const listHeight = Math.max(1, modalHeight - 6)
82
+ const { bodyHeight: listHeight, rowWidth } = searchModalDims(modalWidth, modalHeight)
84
83
  const clampedIndex = clampCommandIndex(selectedIndex, commands)
85
84
  const [scrollTop, setScrollTop] = useState(0)
86
85
  const rows = useMemo(() => buildCommandPaletteRows(commands), [commands])
@@ -88,18 +87,6 @@ export const CommandPalette = ({
88
87
  const visibleRows = rows.slice(scrollTop, scrollTop + listHeight)
89
88
  const bottomPaddingRows = Math.max(0, listHeight - visibleRows.length)
90
89
  const countText = commands.length === 1 ? "1 command" : `${commands.length} commands`
91
- const placeholder = "Search"
92
- const titleText = "Commands"
93
- const headerGap = 1
94
- const headerDivider = "│"
95
- const searchGap = 1
96
- const dividerColumn = 1 + titleText.length + headerGap
97
- const searchStart = titleText.length + headerGap + headerDivider.length + searchGap
98
- const countGap = countText.length > 0 ? 2 : 0
99
- const searchWidth = Math.max(1, contentWidth - searchStart - countGap - countText.length)
100
- const queryText = trimCell(query, Math.max(0, searchWidth - 1))
101
- const queryPadding = Math.max(0, searchWidth - queryText.length - 1)
102
- const caretFg = colors.background === "transparent" ? colors.text : colors.background
103
90
  const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
104
91
  const emptyBottomRows = Math.max(0, listHeight - emptyTopRows - 1)
105
92
  const runCommandOnMouseDown = (command: AppCommand) => (event: MouseEvent) => {
@@ -171,46 +158,19 @@ export const CommandPalette = ({
171
158
  setScrollTop((current) => commandPaletteScrollTop({ current, rowsLength: rows.length, listHeight, selectedRowIndex }))
172
159
  }, [listHeight, rows.length, selectedRowIndex])
173
160
  return (
174
- <ModalFrame
161
+ <SearchModalFrame
175
162
  left={offsetLeft}
176
163
  top={offsetTop}
177
164
  width={modalWidth}
178
165
  height={modalHeight}
179
- junctionRows={[1, modalHeight - 4]}
180
- topJunctionColumns={[dividerColumn]}
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" }]} />}
181
172
  >
182
- <PaddedRow>
183
- <TextLine>
184
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>{titleText}</span>
185
- <span>{" ".repeat(headerGap)}</span>
186
- <span fg={colors.separator}>{headerDivider}</span>
187
- <span>{" ".repeat(searchGap)}</span>
188
- {query.length > 0 ? (
189
- <>
190
- <span fg={colors.text}>{queryText}</span>
191
- <span bg={colors.muted} fg={caretFg}> </span>
192
- {queryPadding > 0 ? <span>{" ".repeat(queryPadding)}</span> : null}
193
- </>
194
- ) : (
195
- <>
196
- <span bg={colors.muted} fg={caretFg}>{placeholder[0]}</span>
197
- <span fg={colors.muted}>{fitCell(placeholder.slice(1), Math.max(0, searchWidth - 1))}</span>
198
- </>
199
- )}
200
- {countText.length > 0 && searchWidth > placeholder.length ? (
201
- <>
202
- <span>{" ".repeat(countGap)}</span>
203
- <span fg={colors.muted}>{countText}</span>
204
- </>
205
- ) : null}
206
- </TextLine>
207
- </PaddedRow>
208
- <Divider width={innerWidth} junctionAt={dividerColumn} junctionChar="┴" />
209
- <box height={listHeight} flexDirection="column" onMouseScroll={handleMouseScroll}>{content}</box>
210
- <Divider width={innerWidth} />
211
- <PaddedRow>
212
- <HintRow items={[{ key: "↑↓", label: "select" }, { key: "enter", label: "run" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "close" }]} />
213
- </PaddedRow>
214
- </ModalFrame>
173
+ {content}
174
+ </SearchModalFrame>
215
175
  )
216
176
  }
@@ -345,35 +345,43 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
345
345
 
346
346
  const conversationDividerBodyRow = (pullRequest: PullRequestItem, contentWidth: number, conversationItems: readonly PullRequestConversationItem[], conversationStatus: DetailConversationStatus) => {
347
347
  if (!pullRequest.detailLoaded) return null
348
- const summaryRows = bodyPreview(pullRequest.body, contentWidth, DETAIL_BODY_SCROLL_LIMIT)
349
- const conversationRows = conversationPreview({
350
- items: conversationItems,
351
- status: conversationStatus,
352
- width: contentWidth,
353
- limit: Math.max(0, DETAIL_BODY_SCROLL_LIMIT - summaryRows.length - 1),
354
- })
355
- return conversationRows.length > 0 ? summaryRows.length : null
348
+ const dividerIndex = detailBodyPreview({ pullRequest, contentWidth, limit: DETAIL_BODY_SCROLL_LIMIT, conversationItems, conversationStatus })
349
+ .findIndex((line) => line.divider === true)
350
+ return dividerIndex >= 0 ? dividerIndex : null
356
351
  }
357
352
 
358
- export const getDetailJunctionRows = (
359
- pullRequest: PullRequestItem | null,
360
- paneWidth: number,
353
+ export const getDetailJunctionRows = ({
354
+ pullRequest,
355
+ paneWidth,
361
356
  showChecks = false,
362
- contentWidth = Math.max(1, paneWidth - 2),
363
- conversationItems: readonly PullRequestConversationItem[] = [],
364
- conversationStatus: DetailConversationStatus = "idle",
365
- ): readonly number[] => {
357
+ contentWidth,
358
+ conversationItems = [],
359
+ conversationStatus = "idle",
360
+ bodyScrollTop = 0,
361
+ bodyViewportHeight = Number.POSITIVE_INFINITY,
362
+ }: {
363
+ readonly pullRequest: PullRequestItem | null
364
+ readonly paneWidth: number
365
+ readonly showChecks?: boolean
366
+ readonly contentWidth?: number
367
+ readonly conversationItems?: readonly PullRequestConversationItem[]
368
+ readonly conversationStatus?: DetailConversationStatus
369
+ readonly bodyScrollTop?: number
370
+ readonly bodyViewportHeight?: number
371
+ }): readonly number[] => {
366
372
  if (!pullRequest) return [DETAIL_PLACEHOLDER_ROWS]
373
+ const resolvedContentWidth = contentWidth ?? Math.max(1, paneWidth - 2)
367
374
  const titleLines = wrapText(pullRequest.title, Math.max(1, paneWidth - 2)).length
368
375
  const detailDividerRow = 1 + titleLines + 1
369
376
  const checks = deduplicateChecks(pullRequest.checks)
370
377
  const checksDividerRow = checks.length > 0 ? detailDividerRow + 1 + checksRowCount(checks) + 1 : -1
371
378
  const headerHeight = getDetailHeaderHeight(pullRequest, paneWidth, showChecks)
372
- const conversationDivider = conversationDividerBodyRow(pullRequest, contentWidth, conversationItems, conversationStatus)
379
+ const conversationDivider = conversationDividerBodyRow(pullRequest, resolvedContentWidth, conversationItems, conversationStatus)
380
+ const visibleConversationDivider = conversationDivider === null ? null : conversationDivider - Math.max(0, Math.floor(bodyScrollTop))
373
381
  return [
374
382
  detailDividerRow,
375
383
  showChecks && checks.length > 0 ? checksDividerRow : -1,
376
- conversationDivider === null ? -1 : headerHeight + conversationDivider,
384
+ visibleConversationDivider === null || visibleConversationDivider < 0 || visibleConversationDivider >= bodyViewportHeight ? -1 : headerHeight + visibleConversationDivider,
377
385
  ].filter((row) => row >= 0)
378
386
  }
379
387
 
@@ -1,7 +1,7 @@
1
1
  import type { DiffRenderable, MouseEvent, ScrollBoxRenderable } from "@opentui/core"
2
2
  import { useMemo, type Ref } from "react"
3
3
  import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
4
- import { colors, type ThemeId } from "./colors.js"
4
+ import { colors, lineNumberTextColor, type ThemeId } from "./colors.js"
5
5
  import { CommentBodyLine, commentCountText, commentMetaSegments, CommentSegmentsLine } from "./comments.js"
6
6
  import { createDiffSyntaxStyle, diffCommentAnchorLabel, diffCommentLineLabel, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileIndexAtLine, type DiffFileStats, type DiffView, type DiffWhitespaceMode, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
7
7
  import { LoadingPane, StatusCard } from "./DetailsPane.js"
@@ -162,6 +162,7 @@ export const PullRequestDiffPane = ({
162
162
  return ` ${selectedCommentLabel ?? diffCommentAnchorLabel(selectedCommentAnchor)}`
163
163
  }
164
164
  const stickyCommentColor = selectedCommentAnchor?.side === "LEFT" ? colors.status.failing : colors.status.passing
165
+ const diffLineNumberFg = lineNumberTextColor(colors.diff.lineNumberBg, colors.text)
165
166
  const handleDiffMouseDown = function (this: ScrollBoxRenderable, event: MouseEvent) {
166
167
  if (event.button !== 0) return
167
168
  const localY = event.y - this.viewport.y
@@ -201,7 +202,7 @@ export const PullRequestDiffPane = ({
201
202
  contextBg={colors.diff.contextBg}
202
203
  addedSignColor={colors.status.passing}
203
204
  removedSignColor={colors.status.failing}
204
- lineNumberFg={colors.muted}
205
+ lineNumberFg={diffLineNumberFg}
205
206
  lineNumberBg={colors.diff.lineNumberBg}
206
207
  addedLineNumberBg={colors.diff.addedLineNumberBg}
207
208
  removedLineNumberBg={colors.diff.removedLineNumberBg}
@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core"
2
2
  import type { LoadStatus, PullRequestItem } from "../domain.js"
3
3
  import { daysOpen } from "../date.js"
4
4
  import { colors } from "./colors.js"
5
- import { fitCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
5
+ import { fitCell, MatchedCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
6
6
  import { pullRequestRowDisplay, repoColor, reviewIcon } from "./pullRequests.js"
7
7
 
8
8
  export type PullRequestGroups = Array<[string, PullRequestItem[]]>
@@ -37,22 +37,6 @@ const groupAgeWidth = (pullRequests: readonly PullRequestItem[]) => {
37
37
  return Math.max(4, maxLen + 1)
38
38
  }
39
39
 
40
- const MatchedCell = ({ text, width, query, align = "left" }: { text: string; width: number; query: string; align?: "left" | "right" }) => {
41
- const fitted = fitCell(text, width, align)
42
- const needle = query.trim().toLowerCase()
43
- const index = needle.length > 0 ? fitted.toLowerCase().indexOf(needle) : -1
44
- if (index < 0) return <span>{fitted}</span>
45
-
46
- const end = Math.min(fitted.length, index + needle.length)
47
- return (
48
- <>
49
- {index > 0 ? <span>{fitted.slice(0, index)}</span> : null}
50
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>{fitted.slice(index, end)}</span>
51
- {end < fitted.length ? <span>{fitted.slice(end)}</span> : null}
52
- </>
53
- )
54
- }
55
-
56
40
  const GroupTitle = ({ label, color, filterText }: { label: string; color: string; filterText: string }) => (
57
41
  <TextLine>
58
42
  <span fg={color}>{GROUP_ICON} </span>
package/src/ui/colors.ts CHANGED
@@ -154,6 +154,13 @@ const mutedTextColor = (background: string) => {
154
154
 
155
155
  const contrastText = (background: string) => luminance(background) > 128 ? "#000000" : "#ffffff"
156
156
 
157
+ export const lineNumberTextColor = (background: string, foreground: string) => {
158
+ const bg = readableHex(background, foreground)
159
+ const fg = readableHex(foreground, contrastText(bg))
160
+ const contrast = Math.abs(luminance(bg) - luminance(fg))
161
+ return mixHex(bg, fg, contrast < 90 ? 0.62 : 0.5)
162
+ }
163
+
157
164
  const ghuiColors: ColorPalette = {
158
165
  background: "#111018",
159
166
  modalBackground: "#1a1a2e",