@@ -4,18 +4,30 @@ import { computed, onMounted, ref } from 'vue'
44import { useI18n } from ' vue-i18n'
55import {
66 Button ,
7+ Dialog ,
8+ DialogContent ,
9+ DialogDescription ,
10+ DialogFooter ,
11+ DialogHeader ,
12+ DialogTitle ,
713 Empty ,
814 EmptyDescription ,
915 EmptyHeader ,
1016 EmptyMedia ,
1117 EmptyTitle ,
1218 Skeleton ,
1319} from ' @oh-my-github/ui'
14- import { AlertCircle , FileDiff } from ' lucide-vue-next'
20+ import { AlertCircle , FileDiff , MessageSquarePlus , X } from ' lucide-vue-next'
1521import { ChangedFilesTree , ConversationCommentComposer } from ' @/components'
22+ import { useReviewSelection } from ' @/composables/use-review-selection'
1623import {
24+ addPullRequestReviewThread ,
25+ discardPendingPullRequestReview ,
26+ submitPendingPullRequestReview ,
1727 submitPullRequestReview ,
1828 usePullRequestFilesQuery ,
29+ usePullRequestReviewThreadsQuery ,
30+ useReviewThreadsInvalidation ,
1931} from ' @/composables/github/use-pull-requests'
2032
2133const props = defineProps <{
@@ -45,10 +57,38 @@ const showFilesEmpty = computed(() =>
4557 && files .value .length === 0
4658)
4759
60+ const threadsQuery = usePullRequestReviewThreadsQuery (
61+ () => props .owner ,
62+ () => props .repo ,
63+ () => props .number ,
64+ () => props .active ,
65+ )
66+ const pendingReview = computed (() => threadsQuery .data .value ?.pendingReview ?? null )
67+ const { invalidateReviewThreads } = useReviewThreadsInvalidation ()
68+ const { selection, clearSelection } = useReviewSelection ()
69+
4870const reviewBody = ref (' ' )
4971const reviewError = ref <string | null >(null )
5072const submittingEvent = ref <GitHubPullRequestReviewEvent | null >(null )
5173const viewerLogin = ref <string | null >(null )
74+ const isSubmittingThread = ref <' single' | ' review' | null >(null )
75+ const isDiscardDialogOpen = ref (false )
76+ const isDiscardingPending = ref (false )
77+
78+ const activeSelection = computed (() => {
79+ const value = selection .value
80+ if (! value ) return null
81+ if (value .owner !== props .owner || value .repo !== props .repo || value .number !== props .number ) return null
82+ return value
83+ })
84+
85+ const anchorLabel = computed (() => {
86+ const anchor = activeSelection .value
87+ if (! anchor ) return ' '
88+ const range = anchor .startLine === null ? ` L${anchor .line } ` : ` L${anchor .startLine }–L${anchor .line } `
89+ const label = t (' pullRequest.review.anchor.commentingOn' , { path: anchor .path , range })
90+ return anchor .side === ' LEFT' ? ` ${label } ${t (' pullRequest.review.anchor.oldSide' )} ` : label
91+ })
5292
5393const isOwnPullRequest = computed (() =>
5494 viewerLogin .value !== null
@@ -77,27 +117,87 @@ function retryFiles(): void {
77117}
78118
79119async function submitReview(event : GitHubPullRequestReviewEvent ): Promise <void > {
80- if (submittingEvent .value ) return
120+ if (submittingEvent .value || isSubmittingThread . value ) return
81121
122+ const pending = pendingReview .value
82123 const body = reviewBody .value .trim ()
83- if (! body && event !== ' APPROVE' ) return
124+ if (! body && event !== ' APPROVE' && ! pending ) return
84125
85126 submittingEvent .value = event
86127 reviewError .value = null
87128
88129 try {
89- await submitPullRequestReview (props .owner , props .repo , props .number , {
90- event ,
91- ... (body ? { body } : {}),
92- })
130+ if (pending ) {
131+ await submitPendingPullRequestReview (props .owner , props .repo , props .number , {
132+ reviewId: pending .id ,
133+ event ,
134+ ... (body ? { body } : {}),
135+ })
136+ } else {
137+ await submitPullRequestReview (props .owner , props .repo , props .number , {
138+ event ,
139+ ... (body ? { body } : {}),
140+ })
141+ }
93142 reviewBody .value = ' '
143+ invalidateReviewThreads (props .owner , props .repo , props .number )
94144 emit (' refetch' )
95145 } catch {
96146 reviewError .value = t (' pullRequest.review.error' )
97147 } finally {
98148 submittingEvent .value = null
99149 }
100150}
151+
152+ async function submitLineComment(mode : ' single' | ' review' ): Promise <void > {
153+ const anchor = activeSelection .value
154+ const body = reviewBody .value .trim ()
155+ if (! anchor || ! body || isSubmittingThread .value || submittingEvent .value ) return
156+
157+ isSubmittingThread .value = mode
158+ reviewError .value = null
159+
160+ try {
161+ await addPullRequestReviewThread (props .owner , props .repo , props .number , {
162+ pullRequestId: props .pullRequest .nodeId ,
163+ pendingReviewId: pendingReview .value ?.id ?? null ,
164+ mode ,
165+ path: anchor .path ,
166+ side: anchor .side ,
167+ line: anchor .line ,
168+ startLine: anchor .startLine ,
169+ startSide: anchor .startLine === null ? null : anchor .side ,
170+ body ,
171+ })
172+ reviewBody .value = ' '
173+ clearSelection ()
174+ invalidateReviewThreads (props .owner , props .repo , props .number )
175+ emit (' refetch' )
176+ } catch {
177+ reviewError .value = t (' pullRequest.review.threadError' )
178+ } finally {
179+ isSubmittingThread .value = null
180+ }
181+ }
182+
183+ async function discardPendingReview(): Promise <void > {
184+ const pending = pendingReview .value
185+ if (! pending || isDiscardingPending .value ) return
186+
187+ isDiscardingPending .value = true
188+ reviewError .value = null
189+
190+ try {
191+ await discardPendingPullRequestReview (props .owner , props .repo , pending .id )
192+ isDiscardDialogOpen .value = false
193+ invalidateReviewThreads (props .owner , props .repo , props .number )
194+ emit (' refetch' )
195+ } catch {
196+ reviewError .value = t (' pullRequest.review.pending.discardError' )
197+ } finally {
198+ isDiscardingPending .value = false
199+ }
200+ }
101201 </script >
102202
103203<template >
@@ -108,13 +208,36 @@ async function submitReview(event: GitHubPullRequestReviewEvent): Promise<void>
108208 {{ t('pullRequest.review.title') }}
109209 </h2 >
110210 <span
111- v-if =" isOwnPullRequest"
211+ v-if =" pendingReview"
212+ class =" select-none text-body text-muted-foreground"
213+ >
214+ {{ t('pullRequest.review.pending.count', { count: pendingReview.commentCount }) }}
215+ </span >
216+ <span
217+ v-else-if =" isOwnPullRequest"
112218 class =" text-body text-muted-foreground"
113219 >
114220 {{ t('pullRequest.review.ownPullRequest') }}
115221 </span >
116222 </div >
117223
224+ <div
225+ v-if =" activeSelection"
226+ class =" flex min-w-0 items-center gap-2 rounded-lg border border-border bg-card px-3 py-2"
227+ >
228+ <MessageSquarePlus class="size-4 shrink-0 text-muted-foreground" />
229+ <span class =" min-w-0 flex-1 truncate text-body text-foreground" >{{ anchorLabel }}</span >
230+ <Button
231+ :aria-label =" t (' pullRequest.review.anchor.clear' )"
232+ size="icon-sm"
233+ type="button"
234+ variant="ghost"
235+ @click =" clearSelection "
236+ >
237+ <X class="size-3.5 " />
238+ </Button >
239+ </div >
240+
118241 <ConversationCommentComposer
119242 v-model =" reviewBody "
120243 :error =" reviewError "
@@ -124,9 +247,49 @@ async function submitReview(event: GitHubPullRequestReviewEvent): Promise<void>
124247 :repo =" repo "
125248 >
126249 <template #actions >
127- <div class =" flex shrink-0 items-center gap-2" >
250+ <div
251+ v-if =" activeSelection"
252+ class =" flex shrink-0 items-center gap-2"
253+ >
254+ <Button
255+ :disabled =" ! hasBody || Boolean (pendingReview ) || Boolean (isSubmittingThread )"
256+ :loading =" isSubmittingThread === ' single' "
257+ loading-mode="leading"
258+ size="sm"
259+ :title =" pendingReview ? t (' pullRequest.review.singleDisabledPending' ) : undefined "
260+ type="button"
261+ variant="outline"
262+ @click =" submitLineComment (' single' )"
263+ >
264+ {{ t('pullRequest.review.addSingleComment') }}
265+ </Button >
266+ <Button
267+ :disabled =" ! hasBody || Boolean (isSubmittingThread )"
268+ :loading =" isSubmittingThread === ' review' "
269+ loading-mode="leading"
270+ size="sm"
271+ type="button"
272+ @click =" submitLineComment (' review' )"
273+ >
274+ {{ t('pullRequest.review.addToReview') }}
275+ </Button >
276+ </div >
277+ <div
278+ v-else
279+ class =" flex shrink-0 items-center gap-2"
280+ >
281+ <Button
282+ v-if =" pendingReview "
283+ :disabled =" Boolean (submittingEvent ) || isDiscardingPending "
284+ size="sm"
285+ type="button"
286+ variant="ghost"
287+ @click =" isDiscardDialogOpen = true "
288+ >
289+ {{ t('pullRequest.review.pending.discard') }}
290+ </Button >
128291 <Button
129- :disabled =" ! hasBody || Boolean (submittingEvent )"
292+ :disabled =" ( ! hasBody && ! pendingReview ) || Boolean (submittingEvent )"
130293 :loading =" submittingEvent === ' COMMENT' "
131294 loading-mode="leading"
132295 size="sm"
@@ -137,7 +300,7 @@ async function submitReview(event: GitHubPullRequestReviewEvent): Promise<void>
137300 {{ t('pullRequest.review.comment') }}
138301 </Button >
139302 <Button
140- :disabled =" isOwnPullRequest || ! hasBody || Boolean (submittingEvent )"
303+ :disabled =" isOwnPullRequest || ( ! hasBody && ! pendingReview ) || Boolean (submittingEvent )"
141304 :loading =" submittingEvent === ' REQUEST_CHANGES' "
142305 loading-mode="leading"
143306 size="sm"
@@ -236,5 +399,33 @@ async function submitReview(event: GitHubPullRequestReviewEvent): Promise<void>
236399 />
237400 </div >
238401 </section >
402+
403+ <Dialog v-model :open =" isDiscardDialogOpen " >
404+ <DialogContent >
405+ <DialogHeader >
406+ <DialogTitle >{{ t('pullRequest.review.pending.discardTitle') }}</DialogTitle >
407+ <DialogDescription >{{ t('pullRequest.review.pending.discardDescription') }}</DialogDescription >
408+ </DialogHeader >
409+ <DialogFooter >
410+ <Button
411+ :disabled =" isDiscardingPending "
412+ type="button"
413+ variant="ghost"
414+ @click =" isDiscardDialogOpen = false "
415+ >
416+ {{ t('pullRequest.review.pending.discardCancel') }}
417+ </Button >
418+ <Button
419+ :loading =" isDiscardingPending "
420+ loading-mode="leading"
421+ type="button"
422+ variant="destructive"
423+ @click =" discardPendingReview "
424+ >
425+ {{ t('pullRequest.review.pending.discardConfirm') }}
426+ </Button >
427+ </DialogFooter >
428+ </DialogContent >
429+ </Dialog >
239430 </div >
240431</template >
0 commit comments