Skip to content
Clone

feat(web/app): add the pull request domain

Port conversation, files, commits and checks tabs with review, assignee and
label handling against the current pull endpoints.
ZhenYi committed
parent dbb51e7 commit cc0c515 Browse files

14 files changed

+3437 −0

File tree 14
Added view/app/src/domains/pulls/model/api.ts View view/app/src/domains/pulls/model/api.ts
@@ -0,0 +1,329 @@
1/**
2 * Pull-request transport: every call the PR pages make, wrapped in the shared
3 * envelope contract so the UI only ever sees unwrapped rows or an `ApiFailure`.
4 *
5 * PRs are addressable by uuid *or* number (`pull_ref`), so every `pull`
6 * parameter below accepts either form.
7 */
8
9import { type ApiEnvelope, unwrapApi } from '@app/lib'
10import Apis from '@app/services/api'
11import type {
12 ChangedFile,
13 InlineDraft,
14 IssuePullLink,
15 MergeCmd,
16 MergeCommitRow,
17 MergeOutcome,
18 MergeStatus,
19 MyPullScope,
20 NewPullCmd,
21 NewPullCommentCmd,
22 Pull,
23 PullAssignee,
24 PullChecks,
25 PullComment,
26 PullCommentThread,
27 PullEvent,
28 PullGate,
29 PullLabel,
30 PullPage,
31 PullPatchCmd,
32 PullQuery,
33 PullReview,
34 PullReviewer,
35 PullState,
36 PullSummary,
37 ReviewCmd,
38} from './pull'
39
40/** `pull_ref` (number as string or uuid) plus the repo the PR lives in. */
41export interface PullRef {
42 space: string
43 repo: string
44 pull: string
45}
46
47const nz = <T>(value: T | '' | null | undefined): T | undefined => (value ? value : undefined)
48
49const pageOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<PullPage<T>> =>
50 unwrapApi<PullPage<T> | null>(method as PromiseLike<ApiEnvelope<PullPage<T> | null>>).then((p) => ({
51 items: p?.items ?? [],
52 total: p?.total ?? 0,
53 limit: p?.limit ?? 0,
54 offset: p?.offset ?? 0,
55 }))
56
57const listOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T[]> =>
58 unwrapApi<T[] | null>(method as PromiseLike<ApiEnvelope<T[] | null>>).then((rows) => rows ?? [])
59
60const oneOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T> =>
61 unwrapApi<T>(method as PromiseLike<ApiEnvelope<T>>)
62
63/** Mutation endpoints whose payload we ignore (the generated types disagree
64 * on whether `data` is null or the updated entity — accept both). */
65const ignore = <T>(method: PromiseLike<ApiEnvelope<T>>): Promise<null> => unwrapApi(method).then(() => null)
66
67const pathOf = (ref: PullRef) => ({
68 space_name: ref.space,
69 repo_name: ref.repo,
70 pull_ref: ref.pull,
71})
72
73/* ------------------------------------------------------------------- list */
74
75export const listPulls = (space: string, repo: string, query: PullQuery = {}) =>
76 pageOf<Pull>(
77 Apis.pulls.list_pulls({
78 pathParams: { space_name: space, repo_name: repo },
79 params: {
80 // `draft` is a UI-only choice the backend spells as state=open + draft=true.
81 state: query.state === 'all' || !query.state ? 'all' : query.state === 'draft' ? 'open' : query.state,
82 draft: query.state === 'draft' ? true : query.draft,
83 author: nz(query.author),
84 base: nz(query.base),
85 head: nz(query.head),
86 search: nz(query.search),
87 sort: nz(query.sort),
88 order: nz(query.order),
89 limit: query.limit,
90 offset: query.offset ?? 0,
91 },
92 }),
93 )
94
95/** Cross-repo inbox: PRs created by / needing review from / reviewed by me. */
96export const listMyPulls = (
97 scope: MyPullScope,
98 opts: { state?: PullState | 'all' | 'draft'; limit?: number; offset?: number } = {},
99) =>
100 pageOf<Pull>(
101 Apis.pulls.list_my_pulls({
102 pathParams: { scope },
103 params: {
104 // The inbox endpoint has no draft filter; `draft` maps to open.
105 state: opts.state && opts.state !== 'all' && opts.state !== 'draft' ? opts.state : 'open',
106 limit: opts.limit,
107 offset: opts.offset ?? 0,
108 },
109 }),
110 )
111
112export const getPull = (ref: PullRef) => oneOf<Pull>(Apis.pulls.get_pull({ pathParams: pathOf(ref) }))
113
114export const createPull = (space: string, repo: string, cmd: NewPullCmd) =>
115 oneOf<Pull>(
116 Apis.pulls.create_pull({
117 pathParams: { space_name: space, repo_name: repo },
118 data: {
119 title: cmd.title,
120 head_branch: cmd.head_branch,
121 base_branch: cmd.base_branch ?? null,
122 body: cmd.body ?? null,
123 draft: cmd.draft ?? false,
124 issue_id: cmd.issue_id ?? null,
125 },
126 }),
127 )
128
129export const updatePull = (ref: PullRef, cmd: PullPatchCmd) =>
130 oneOf<Pull>(Apis.pulls.update_pull({ pathParams: pathOf(ref), data: cmd }))
131
132export const deletePull = (ref: PullRef) => ignore(Apis.pulls.delete_pull({ pathParams: pathOf(ref) }))
133
134export const closePull = (ref: PullRef) => oneOf<Pull>(Apis.pulls.close_pull({ pathParams: pathOf(ref) }))
135export const reopenPull = (ref: PullRef) => oneOf<Pull>(Apis.pulls.reopen_pull({ pathParams: pathOf(ref) }))
136
137export const lockPull = (ref: PullRef) => ignore(Apis.pulls.lock_pull({ pathParams: pathOf(ref) }))
138export const unlockPull = (ref: PullRef) => ignore(Apis.pulls.unlock_pull({ pathParams: pathOf(ref) }))
139
140/* ----------------------------------------------------------------- merge */
141
142export const mergePull = (ref: PullRef, cmd: MergeCmd) =>
143 oneOf<MergeOutcome>(Apis.pulls.merge_pull({ pathParams: pathOf(ref), data: cmd }))
144
145export const updateBranchPull = (ref: PullRef) =>
146 oneOf<Pull>(Apis.pulls.update_branch_pull({ pathParams: pathOf(ref) }))
147
148export const getPullGate = (ref: PullRef) => oneOf<PullGate>(Apis.pulls.get_pull_gate({ pathParams: pathOf(ref) }))
149
150export const refreshPullSummary = (ref: PullRef) =>
151 oneOf<PullSummary>(Apis.pulls.refresh_pull_summary({ pathParams: pathOf(ref) }))
152
153/** Mergeability probe for the create dialog (`GET …/conflicts?base=&head=`). */
154export const checkMergeStatus = (space: string, repo: string, base: string, head: string) =>
155 oneOf<MergeStatus>(
156 Apis.conflicts.get_conflicts({
157 pathParams: { space_name: space, repo_name: repo },
158 params: { base, head },
159 }),
160 )
161
162/** Raw `.patch` download URL (browser navigation, not a JSON call). */
163export const pullPatchUrl = (ref: PullRef) =>
164 `/api/v1/repos/${encodeURIComponent(ref.space)}/${encodeURIComponent(ref.repo)}/pulls/${encodeURIComponent(ref.pull)}/patch`
165
166/* --------------------------------------------------------------- commits */
167
168export const listPullCommits = (ref: PullRef) =>
169 listOf<MergeCommitRow>(Apis.pulls.list_pull_commits({ pathParams: pathOf(ref) }))
170
171export const listPullFiles = (ref: PullRef) =>
172 listOf<ChangedFile>(Apis.pulls.get_pull_files({ pathParams: pathOf(ref) }))
173
174/* ---------------------------------------------------------------- checks */
175
176export const getPullChecks = (ref: PullRef) =>
177 oneOf<PullChecks>(Apis.pulls.get_pull_checks({ pathParams: pathOf(ref) }))
178
179/* --------------------------------------------------------------- comment */
180
181export const listCommentTree = (ref: PullRef) =>
182 listOf<PullCommentThread>(Apis.pulls.list_pull_comment_tree({ pathParams: pathOf(ref) }))
183
184export const listCommentsPage = (ref: PullRef, opts: { limit?: number; offset?: number } = {}) =>
185 pageOf<PullComment>(
186 Apis.pulls.list_pull_comments({
187 pathParams: pathOf(ref),
188 params: { limit: opts.limit, offset: opts.offset ?? 0 },
189 }),
190 )
191
192export const createComment = (ref: PullRef, cmd: NewPullCommentCmd) =>
193 oneOf<PullComment>(
194 Apis.pulls.create_pull_comment({
195 pathParams: pathOf(ref),
196 data: {
197 body: cmd.body,
198 parent_comment_id: cmd.parent_comment_id ?? null,
199 path: cmd.path ?? null,
200 line: cmd.line ?? null,
201 side: cmd.side ?? null,
202 commit_sha: cmd.commit_sha ?? null,
203 review_id: cmd.review_id ?? null,
204 },
205 }),
206 )
207
208export const updateComment = (ref: PullRef, commentId: string, body: string) =>
209 oneOf<PullComment>(
210 Apis.pulls.update_pull_comment({
211 pathParams: { ...pathOf(ref), comment_id: commentId },
212 data: { body },
213 }),
214 )
215
216export const deleteComment = (ref: PullRef, commentId: string) =>
217 ignore(Apis.pulls.delete_pull_comment({ pathParams: { ...pathOf(ref), comment_id: commentId } }))
218
219export const resolveComment = (ref: PullRef, commentId: string) =>
220 ignore(Apis.pulls.resolve_pull_comment({ pathParams: { ...pathOf(ref), comment_id: commentId } }))
221
222export const unresolveComment = (ref: PullRef, commentId: string) =>
223 ignore(Apis.pulls.unresolve_pull_comment({ pathParams: { ...pathOf(ref), comment_id: commentId } }))
224
225/* ---------------------------------------------------------------- review */
226
227export const listReviews = (ref: PullRef) => listOf<PullReview>(Apis.reviews.list_reviews({ pathParams: pathOf(ref) }))
228
229export const getReview = (ref: PullRef, reviewId: string) =>
230 oneOf<PullReview>(Apis.reviews.get_review({ pathParams: { ...pathOf(ref), review_id: reviewId } }))
231
232export const submitReview = (ref: PullRef, cmd: ReviewCmd) =>
233 oneOf<PullReview>(Apis.reviews.submit_review({ pathParams: pathOf(ref), data: cmd }))
234
235/** Publish a pending review (optionally with final body/inline drafts). */
236export const publishReview = (ref: PullRef, reviewId: string, cmd: ReviewCmd) =>
237 oneOf<PullReview>(Apis.reviews.publish_review({ pathParams: { ...pathOf(ref), review_id: reviewId }, data: cmd }))
238
239export const dismissReview = (ref: PullRef, reviewId: string) =>
240 oneOf<PullReview>(Apis.reviews.dismiss_review({ pathParams: { ...pathOf(ref), review_id: reviewId } }))
241
242export const listRequestedReviewers = (ref: PullRef) =>
243 listOf<PullReviewer>(Apis.reviews.list_requested_reviewers({ pathParams: pathOf(ref) }))
244
245export const requestReviewers = (ref: PullRef, reviewers: string[], required?: boolean) =>
246 listOf<PullReviewer>(
247 Apis.reviews.request_reviewers({
248 pathParams: pathOf(ref),
249 data: { reviewers, required: required ?? undefined },
250 }),
251 )
252
253export const removeRequestedReviewer = (ref: PullRef, userId: number) =>
254 ignore(Apis.reviews.delete_requested_reviewer({ pathParams: { ...pathOf(ref), user_id: userId } }))
255
256/* ------------------------------------------------------------- meta side */
257
258export const listEvents = (ref: PullRef, opts: { limit?: number; offset?: number } = {}) =>
259 pageOf<PullEvent>(
260 Apis.pulls.list_pull_events({
261 pathParams: pathOf(ref),
262 params: { limit: opts.limit, offset: opts.offset ?? 0 },
263 }),
264 )
265
266export const listAssignees = (ref: PullRef) =>
267 listOf<PullAssignee>(Apis.pulls.list_pull_assignees({ pathParams: pathOf(ref) }))
268
269export const addAssignee = (ref: PullRef, userRef: string) =>
270 ignore(Apis.pulls.add_pull_assignee({ pathParams: { ...pathOf(ref), user_ref: userRef } }))
271
272export const removeAssignee = (ref: PullRef, userRef: string) =>
273 ignore(Apis.pulls.remove_pull_assignee({ pathParams: { ...pathOf(ref), user_ref: userRef } }))
274
275export const listPullLabels = (ref: PullRef) =>
276 listOf<PullLabel>(Apis.pulls.list_pull_labels({ pathParams: pathOf(ref) }))
277
278/** 可附加的标签候选(仓库目录标签库,未归档),供侧栏搜索下拉使用。 */
279export const listPullLabelOptions = (space: string, repo: string) =>
280 listOf<PullLabel>(Apis.pulls.list_label_options({ pathParams: { space_name: space, repo_name: repo } }))
281
282export const attachLabel = (ref: PullRef, label: string) =>
283 ignore(Apis.pulls.attach_pull_label({ pathParams: pathOf(ref), data: { label } }))
284
285export const detachLabel = (ref: PullRef, labelRef: string) =>
286 ignore(Apis.pulls.detach_pull_label({ pathParams: { ...pathOf(ref), label_ref: labelRef } }))
287
288/* ----------------------------------------------------- issue-side links */
289
290/** PRs linked to one issue (`GET …/issues/{id}/pulls`), with relation ids. */
291export const listIssuePulls = (space: string, board: string, issueId: string) =>
292 listOf<IssuePullLink>(
293 Apis.pulls.list_issue_pulls({
294 pathParams: { space_name: space, board_id: board, issue_id: issueId },
295 }),
296 )
297
298export const linkIssuePull = (
299 space: string,
300 board: string,
301 issueId: string,
302 cmd: { space_name: string; repo_name: string; pull_ref: string },
303) =>
304 ignore(
305 Apis.pulls.link_issue_pull({
306 pathParams: { space_name: space, board_id: board, issue_id: issueId },
307 data: cmd,
308 }),
309 )
310
311export const unlinkIssuePull = (space: string, board: string, issueId: string, relationId: string) =>
312 ignore(
313 Apis.pulls.unlink_issue_pull({
314 pathParams: {
315 space_name: space,
316 board_id: board,
317 issue_id: issueId,
318 relation_id: relationId,
319 },
320 }),
321 )
322
323/** Inline-draft helper kept next to the transport the files tab uses. */
324export const draftFrom = (path: string, line: number, side: string | null, body: string): InlineDraft => ({
325 path,
326 line,
327 side,
328 body,
329})
Added view/app/src/domains/pulls/model/filters.tsx View view/app/src/domains/pulls/model/filters.tsx
@@ -0,0 +1,64 @@
1import { t } from '@app/i18n'
2import type { PullFilter } from './meta'
3import { PULL_STATE_LABEL } from './meta'
4
5export interface PullFilterValue {
6 state: PullFilter
7 search: string
8}
9
10const CHOICES: { value: PullFilter; get label(): string }[] = [
11 {
12 value: 'open',
13 get label() {
14 return PULL_STATE_LABEL.open
15 },
16 },
17 {
18 value: 'merged',
19 get label() {
20 return PULL_STATE_LABEL.merged
21 },
22 },
23 {
24 value: 'closed',
25 get label() {
26 return PULL_STATE_LABEL.closed
27 },
28 },
29 {
30 value: 'draft',
31 get label() {
32 return t('pull.badge.draft')
33 },
34 },
35 {
36 value: 'all',
37 get label() {
38 return t('common.state.all')
39 },
40 },
41]
42
43/** State segmented control shared by the list tab and the inbox. */
44export function PullFilter({ value, onChange }: { value: PullFilterValue; onChange: (next: PullFilterValue) => void }) {
45 return (
46 <div className="flex items-center gap-0.5 self-start rounded-md bg-shell-field p-0.5">
47 {CHOICES.map((choice) => (
48 <button
49 key={choice.value}
50 type="button"
51 aria-pressed={value.state === choice.value}
52 onClick={() => onChange({ ...value, state: choice.value })}
53 className={`rounded px-2.5 py-1 text-[12.5px] transition-colors ${
54 value.state === choice.value
55 ? 'bg-shell-active font-semibold text-shell-ink'
56 : 'text-shell-ink-muted hover:text-shell-ink'
57 }`}
58 >
59 {choice.label}
60 </button>
61 ))}
62 </div>
63 )
64}
Added view/app/src/domains/pulls/model/index.ts View view/app/src/domains/pulls/model/index.ts
@@ -0,0 +1,8 @@
1/**
2 * Public API of entities/pull-request — the PR domain model shared by the
3 * pull-request feature and the issue board's linked-PR panel. Consumers import
4 * from '@app/domains/pulls/model', never from deeper files.
5 */
6
7export * from './api'
8export * from './pull'
Added view/app/src/domains/pulls/model/meta.ts View view/app/src/domains/pulls/model/meta.ts
@@ -0,0 +1,145 @@
1import type { ChangedFile, MergeMethod, PullState, ReviewerState, ReviewState, UserDto } from '@app/domains/pulls/model'
2import type { DiffFile, DiffFileStatus } from '@app/domains/repo/model'
3import { type MessageKey, t } from '@app/i18n'
4
5/** UI-only filter: the backend understands open/closed/merged and no filter. */
6export type PullFilter = PullState | 'all' | 'draft'
7
8const PULL_STATE_KEYS: Record<PullState, MessageKey> = {
9 open: 'pull.state.open',
10 closed: 'pull.state.closed',
11 merged: 'pull.state.merged',
12}
13
14/** Lazy so badges follow the active locale. */
15export const PULL_STATE_LABEL = new Proxy({} as Record<PullState, string>, {
16 get: (_target, key: PullState) => t(PULL_STATE_KEYS[key]),
17})
18
19/** tailwind-ish tone classes shared across badges (mirrors repo-history colors). */
20export const TONE = {
21 green: 'text-[#1a7f37] bg-[rgba(35,165,90,0.12)]',
22 red: 'text-[#b42318] bg-[rgba(242,63,67,0.10)]',
23 amber: 'text-[#8a6100] bg-[rgba(240,178,50,0.16)]',
24 violet: 'text-[#6937c3] bg-[rgba(120,82,196,0.12)]',
25 gray: 'text-shell-ink-muted bg-shell-active',
26} as const
27
28const REVIEW_STATE_KEYS: Record<ReviewState, MessageKey> = {
29 approved: 'pull.reviewState.approved',
30 changes_requested: 'pull.reviewState.changesRequested',
31 commented: 'pull.reviewState.commented',
32 pending: 'pull.reviewState.pending',
33 dismissed: 'pull.reviewState.dismissed',
34}
35
36export const REVIEW_STATE_LABEL = new Proxy({} as Record<ReviewState, string>, {
37 get: (_target, key: ReviewState) => t(REVIEW_STATE_KEYS[key]),
38})
39
40export const REVIEW_STATE_TONE: Record<ReviewState, keyof typeof TONE> = {
41 approved: 'green',
42 changes_requested: 'red',
43 commented: 'gray',
44 pending: 'amber',
45 dismissed: 'gray',
46}
47
48const REVIEWER_STATE_KEYS: Record<ReviewerState, MessageKey> = {
49 requested: 'pull.reviewerState.requested',
50 approved: 'pull.reviewerState.approved',
51 changes_requested: 'pull.reviewerState.changesRequested',
52 commented: 'pull.reviewerState.commented',
53 dismissed: 'pull.reviewerState.dismissed',
54}
55
56export const REVIEWER_STATE_LABEL = new Proxy({} as Record<ReviewerState, string>, {
57 get: (_target, key: ReviewerState) => t(REVIEWER_STATE_KEYS[key]),
58})
59
60const MERGE_METHOD_KEYS: Record<MergeMethod, MessageKey> = {
61 merge: 'pull.mergeMethod.merge',
62 squash: 'pull.mergeMethod.squash',
63 rebase: 'pull.mergeMethod.rebase',
64}
65
66/** 合并方式的可选顺序(供 <select> 迭代;Proxy 标签对象本身没有可枚举键)。 */
67export const MERGE_METHODS: MergeMethod[] = ['merge', 'squash', 'rebase']
68
69export const MERGE_METHOD_LABEL = new Proxy({} as Record<MergeMethod, string>, {
70 get: (_target, key: MergeMethod) => t(MERGE_METHOD_KEYS[key]),
71})
72
73/** The backend formats status with `{:?}` (Debug names) — normalize loosely. */
74export function normalizeFileStatus(raw: string): DiffFileStatus {
75 const s = raw.toLowerCase()
76 if (s.startsWith('ad') || s.startsWith('new') || s.startsWith('creat') || s === 'a') return 'added'
77 if (s.startsWith('del') || s.startsWith('rem') || s === 'd') return 'deleted'
78 if (s.startsWith('ren') || s.startsWith('mov') || s === 'r') return 'renamed'
79 if (s.startsWith('cop') || s === 'c') return 'copied'
80 if (s.startsWith('bin')) return 'binary'
81 return 'modified'
82}
83
84const EVENT_KEYS: Record<string, MessageKey> = {
85 created: 'pull.event.created',
86 updated: 'pull.event.updated',
87 title_changed: 'pull.event.titleChanged',
88 body_edited: 'pull.event.bodyEdited',
89 base_changed: 'pull.event.baseChanged',
90 marked_ready: 'pull.event.markedReady',
91 marked_draft: 'pull.event.markedDraft',
92 closed: 'pull.event.closed',
93 reopened: 'pull.event.reopened',
94 merged: 'pull.event.merged',
95 review_requested: 'pull.event.reviewRequested',
96 review_request_removed: 'pull.event.reviewRequestRemoved',
97 review_submitted: 'pull.event.reviewSubmitted',
98 approved: 'pull.event.approved',
99 changes_requested: 'pull.event.changesRequested',
100 comment: 'pull.event.comment',
101 labeled: 'pull.event.labeled',
102 unlabeled: 'pull.event.unlabeled',
103 assigned: 'pull.event.assigned',
104 unassigned: 'pull.event.unassigned',
105 locked: 'pull.event.locked',
106 unlocked: 'pull.event.unlocked',
107 force_push: 'pull.event.forcePush',
108 branch_updated: 'pull.event.branchUpdated',
109 head_ref_updated: 'pull.event.headRefUpdated',
110 auto_merge_enabled: 'pull.event.autoMergeEnabled',
111 auto_merge_disabled: 'pull.event.autoMergeDisabled',
112}
113
114/** Unknown backend event types keep rendering as their raw snake_case name. */
115export const eventLabel = (eventType: string): string =>
116 EVENT_KEYS[eventType] ? t(EVENT_KEYS[eventType]) : eventType.replaceAll('_', ' ')
117
118export const userName = (user?: UserDto | null): string =>
119 user?.display_name || user?.username || t('common.state.unknownUser')
120
121export interface InlineAnchor {
122 path: string
123 line: number
124 side: 'left' | 'right'
125}
126
127export const anchorKey = (path: string, line: number, side: 'left' | 'right') => `${path}#${side}${line}`
128
129/** A pinned inline comment as rendered under its diff line. */
130export interface AnchoredComment {
131 id: string
132 body: string
133}
134
135export function changedFileToDiff(file: ChangedFile): DiffFile {
136 const status = file.binary ? 'binary' : normalizeFileStatus(file.path.status)
137 return {
138 path: file.path.new_path,
139 oldPath: file.path.old_path && file.path.old_path !== file.path.new_path ? file.path.old_path : null,
140 status,
141 raw: file.patch ?? '',
142 additions: file.lines_added,
143 deletions: file.lines_removed,
144 }
145}
Added view/app/src/domains/pulls/model/pull.ts View view/app/src/domains/pulls/model/pull.ts
@@ -0,0 +1,165 @@
1/**
2 * Pull-request (PR) row shapes.
3 *
4 * Types are derived from the generated response envelopes wherever the backend
5 * exposes one, so a schema change in `bun run gen:api` shows up here as a type
6 * error. Paged payloads are typed as the generic `Page` schema by the
7 * generator, so their rows are cast structurally in `api.ts`.
8 */
9import type {
10 EnvelopeMergePullDto,
11 EnvelopeMergeStatusDto,
12 EnvelopePullChecksDto,
13 EnvelopePullCommentDto,
14 EnvelopePullDto,
15 EnvelopePullGateDto,
16 EnvelopePullReviewDto,
17 EnvelopePullSummaryDto,
18 EnvelopeVecChangedPathDiffDto,
19 EnvelopeVecIssuePullLinkDto,
20 EnvelopeVecLabelDto,
21 EnvelopeVecMergeCommitDto,
22 EnvelopeVecPullAssigneeDto,
23 EnvelopeVecPullCommentThreadDto,
24 EnvelopeVecPullReviewerDto,
25 MergeMethod,
26 PullState,
27 ReviewerState,
28 ReviewIntent,
29 ReviewState,
30 UserDto,
31} from '@app/services/api'
32
33export type { MergeMethod, PullState, ReviewerState, ReviewIntent, ReviewState, UserDto }
34
35export const PULL_STATES = ['open', 'closed', 'merged'] as const
36export const MY_PULL_SCOPES = ['created', 'review-requested', 'reviewed'] as const
37export type MyPullScope = (typeof MY_PULL_SCOPES)[number]
38
39/** Full pull payload (`GET …/pulls/{pull_ref}`). */
40export type Pull = NonNullable<EnvelopePullDto['data']>
41
42export type PullComment = NonNullable<EnvelopePullCommentDto['data']>
43
44export type PullCommentThread = NonNullable<NonNullable<EnvelopeVecPullCommentThreadDto['data']>[number]>
45
46export type PullReview = NonNullable<EnvelopePullReviewDto['data']>
47
48export type PullReviewer = NonNullable<NonNullable<EnvelopeVecPullReviewerDto['data']>[number]>
49
50export type PullAssignee = NonNullable<NonNullable<EnvelopeVecPullAssigneeDto['data']>[number]>
51
52export type PullChecks = NonNullable<EnvelopePullChecksDto['data']>
53
54export type PullGate = NonNullable<EnvelopePullGateDto['data']>
55
56/** The gate embedded in `PullGate` (no standalone response envelope). */
57export type MergeGate = PullGate['gate']
58
59export type PullSummary = NonNullable<EnvelopePullSummaryDto['data']>
60
61/** `{pull, result, summary}` of a successful merge. */
62export type MergeOutcome = NonNullable<EnvelopeMergePullDto['data']>
63
64export type MergeStatus = NonNullable<EnvelopeMergeStatusDto['data']>
65
66export type MergeCommitRow = NonNullable<NonNullable<EnvelopeVecMergeCommitDto['data']>[number]>
67
68/** One changed file with its patch section (`GET …/files`). */
69export type ChangedFile = NonNullable<NonNullable<EnvelopeVecChangedPathDiffDto['data']>[number]>
70
71/** Activity line of `GET …/events` (paged generically, so declared here). */
72export interface PullEvent {
73 id: string
74 pull_request_id: string
75 actor?: UserDto | null
76 event_type: string
77 commit_sha?: string | null
78 content?: string | null
79 created_at: string
80}
81
82/** A board label bound to a PR (`GET …/labels` → Vec<LabelDto>). */
83export type PullLabel = NonNullable<NonNullable<EnvelopeVecLabelDto['data']>[number]>
84
85/** Issue↔PR relation row (`GET …/issues/{id}/pulls`). */
86export type IssuePullLink = NonNullable<NonNullable<EnvelopeVecIssuePullLinkDto['data']>[number]>
87
88/** Offset page as emitted by the backend. */
89export interface PullPage<T> {
90 items: T[]
91 total: number
92 limit: number
93 offset: number
94}
95
96/** Filters accepted by `GET …/pulls` (plus paging). */
97export interface PullQuery {
98 /** `draft` is UI-only: it maps to state=open + draft=true server-side. */
99 state?: PullState | 'all' | 'draft'
100 author?: string
101 base?: string
102 head?: string
103 draft?: boolean
104 search?: string
105 sort?: string
106 order?: string
107 limit?: number
108 offset?: number
109}
110
111/** Body of `POST …/pulls/{ref}`. */
112export interface PullPatchCmd {
113 title?: string
114 body?: string | null
115 base_branch?: string | null
116 draft?: boolean | null
117 auto_merge?: boolean | null
118 issue_id?: string | null
119}
120
121/** Body of `POST …/pulls` (create). */
122export interface NewPullCmd {
123 title: string
124 head_branch: string
125 base_branch?: string | null
126 body?: string | null
127 draft?: boolean
128 issue_id?: string | null
129}
130
131/** Body of `POST …/merge`. */
132export interface MergeCmd {
133 method?: MergeMethod
134 title?: string | null
135 message?: string | null
136 fast_forward_only?: boolean
137 delete_branch_after_merge?: boolean
138 expect_head_sha?: string | null
139}
140
141/** Inline comment draft carried by `POST …/reviews` (submit review). */
142export interface InlineDraft {
143 path: string
144 line: number
145 side?: string | null
146 body?: string | null
147}
148
149/** Body of `POST …/reviews` and `POST …/reviews/{id}/submit`. */
150export interface ReviewCmd {
151 state: ReviewIntent
152 body?: string | null
153 comments?: InlineDraft[]
154}
155
156/** Body of `POST …/comments` — conversation or inline (path+line). */
157export interface NewPullCommentCmd {
158 body: string
159 parent_comment_id?: string | null
160 path?: string | null
161 line?: number | null
162 side?: string | null
163 commit_sha?: string | null
164 review_id?: string | null
165}
Added view/app/src/domains/pulls/ui/checks-tab.tsx View view/app/src/domains/pulls/ui/checks-tab.tsx
@@ -0,0 +1,70 @@
1import { ErrorPanel, PanelNote } from '@app/components'
2import { getPullChecks, type PullRef } from '@app/domains/pulls/model'
3import type { MessageKey } from '@app/i18n'
4import { useT } from '@app/i18n'
5import { useAsync } from '@app/lib'
6import { useState } from 'react'
7import { Copyable } from './parts'
8
9type CheckState = { labelKey: MessageKey | null; tone: string }
10
11const STATUS_STATE: Record<string, CheckState> = {
12 pending: { labelKey: 'pull.checks.waiting', tone: 'text-shell-ink-muted' },
13 success: { labelKey: 'pull.checks.success', tone: 'text-[#1a7f37]' },
14 failure: { labelKey: 'pull.checks.failed', tone: 'text-[#b42318]' },
15 error: { labelKey: 'pull.checks.error', tone: 'text-[#b42318]' },
16}
17
18/** 检查页签:最新 head 的提交状态。 */
19export function ChecksTab({ refObj, headSha }: { refObj: PullRef; headSha: string }) {
20 const { t } = useT()
21 const [tick, setTick] = useState(0)
22 const checks = useAsync(() => getPullChecks(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
23 const failure = checks.error as { code?: number; message?: string } | undefined
24 if (checks.loading && !checks.data) return <PanelNote text={t('pull.checks.loading')} />
25 if (failure)
26 return (
27 <ErrorPanel
28 status={failure.code ?? 0}
29 what={t('pull.checks.errorChecks')}
30 note={failure.message}
31 onRetry={() => setTick((tick) => tick + 1)}
32 />
33 )
34 const data = checks.data
35 if (!data) return <PanelNote text={t('pull.checks.empty')} />
36 const statuses = data.statuses ?? []
37 if (!statuses.length) {
38 return <PanelNote text={t('pull.checks.versionEmpty', { sha: (data.head_sha || headSha).slice(0, 8) })} />
39 }
40 return (
41 <div className="space-y-3">
42 <p className="text-[12.5px] text-shell-ink-muted">
43 {t('pull.checks.version')} <Copyable text={data.head_sha || headSha} />
44 </p>
45 {statuses.map((status, index) => {
46 const hit = STATUS_STATE[status.state] ?? { labelKey: null, tone: 'text-shell-ink-muted' }
47 return (
48 <div
49 key={`${status.context}:${index}`}
50 className="flex flex-wrap items-center gap-2 rounded-xl border border-shell-hairline bg-shell-surface px-4 py-2.5 text-[13px]"
51 >
52 <span className={`h-2 w-2 shrink-0 rounded-full bg-current ${hit.tone}`} aria-hidden />
53 <span className="font-medium">{status.context}</span>
54 <span className={`text-[12px] ${hit.tone}`}>{hit.labelKey ? t(hit.labelKey) : status.state}</span>
55 {status.description ? <span className="text-[12px] text-shell-ink-muted">{status.description}</span> : null}
56 {status.target_url ? (
57 <button
58 type="button"
59 onClick={() => status.target_url && window.open(status.target_url, '_blank', 'noopener,noreferrer')}
60 className="ml-auto text-[12px] text-shell-ink-muted underline underline-offset-2 hover:text-shell-ink"
61 >
62 {t('pull.checks.details')}
63 </button>
64 ) : null}
65 </div>
66 )
67 })}
68 </div>
69 )
70}
Added view/app/src/domains/pulls/ui/commits-tab.tsx View view/app/src/domains/pulls/ui/commits-tab.tsx
@@ -0,0 +1,68 @@
1import { ErrorPanel, PanelNote } from '@app/components'
2import { listPullCommits, type PullRef } from '@app/domains/pulls/model'
3import { useT } from '@app/i18n'
4import { fmtRelative, fmtTime, useAsync } from '@app/lib'
5import { useState } from 'react'
6import { Copyable } from './parts'
7
8export interface CommitsTabProps {
9 refObj: PullRef
10 onOpenCommit: (sha: string) => void
11}
12
13/** 提交页签:head…base 区间内的提交(后端给 MergeCommitDto)。 */
14export function CommitsTab({ refObj, onOpenCommit }: CommitsTabProps) {
15 const { t } = useT()
16 const [tick, setTick] = useState(0)
17 const commits = useAsync(() => listPullCommits(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
18 const failure = commits.error as { code?: number; message?: string } | undefined
19 if (commits.loading && !commits.data) return <PanelNote text={t('pull.commits.loading')} />
20 if (failure)
21 return (
22 <ErrorPanel
23 status={failure.code ?? 0}
24 what={t('pull.commits.errorList')}
25 note={failure.message}
26 onRetry={() => setTick((tick) => tick + 1)}
27 />
28 )
29 const rows = commits.data ?? []
30 if (!rows.length) return <PanelNote text={t('pull.commits.empty')} />
31 return (
32 <ul className="divide-y divide-shell-hairline rounded-xl border border-shell-hairline bg-shell-surface">
33 {rows.map((commit) => (
34 <li key={commit.sha} className="flex flex-wrap items-start gap-3 px-4 py-3">
35 <div className="min-w-0 flex-1">
36 <button
37 type="button"
38 onClick={() => onOpenCommit(commit.sha)}
39 className="block max-w-full truncate text-left text-[13.5px] font-medium leading-6 hover:underline"
40 title={commit.subject}
41 >
42 {commit.subject || t('pull.commits.noSubject')}
43 </button>
44 <p className="text-[12px] text-shell-ink-muted">
45 {commit.author_name || t('pull.commits.unknownAuthor')} &lt;{commit.author_email}&gt; ·{' '}
46 <span title={fmtTime(commit.author_at)}>{fmtRelative(commit.author_at)}</span>
47 </p>
48 {commit.parents.length > 1 ? (
49 <p className="mt-0.5 text-[11.5px] text-shell-ink-muted">
50 {t('pull.commits.mergeCommit', { count: commit.parents.length })}
51 </p>
52 ) : null}
53 </div>
54 <span className="flex shrink-0 items-center gap-1.5">
55 <Copyable text={commit.sha} />
56 <button
57 type="button"
58 onClick={() => onOpenCommit(commit.sha)}
59 className="rounded-md border border-shell-hairline px-2 py-1 text-[11.5px] text-shell-ink-muted transition-colors hover:text-shell-ink"
60 >
61 {t('pull.commits.view')}
62 </button>
63 </span>
64 </li>
65 ))}
66 </ul>
67 )
68}
Added view/app/src/domains/pulls/ui/conversation.tsx View view/app/src/domains/pulls/ui/conversation.tsx
@@ -0,0 +1,529 @@
1import { Alert, Markdown } from '@app/components'
2import type {
3 Pull,
4 PullComment,
5 PullCommentThread,
6 PullEvent,
7 PullRef,
8 PullReview,
9 ReviewIntent,
10} from '@app/domains/pulls/model'
11import {
12 createComment,
13 deleteComment,
14 dismissReview,
15 getReview,
16 listCommentTree,
17 listEvents,
18 listReviews,
19 publishReview,
20 resolveComment,
21 submitReview,
22 unresolveComment,
23 updateComment,
24} from '@app/domains/pulls/model'
25import { type MessageKey, useT } from '@app/i18n'
26import { fmtRelative, fmtTime, useAsync } from '@app/lib'
27import { Button } from '@app/ui'
28import { Check, CheckCheck, Lock, MessageSquare, Pencil, Reply, RotateCcw, Send, Trash2 } from 'lucide-react'
29import { type ReactNode, useMemo, useState } from 'react'
30import { eventLabel, REVIEW_STATE_LABEL, REVIEW_STATE_TONE, TONE, userName } from '../model/meta'
31import { Avatar, Copyable, SideSection } from './parts'
32
33type Row =
34 | { kind: 'thread'; at: string; thread: PullCommentThread }
35 | { kind: 'review'; at: string; review: PullReview }
36 | { kind: 'event'; at: string; event: PullEvent }
37
38export interface ConversationProps {
39 refObj: PullRef
40 pull: Pull
41 onChanged: () => void
42 /** Jump to the files tab anchored at a path (from an inline comment). */
43 onOpenFile: (path: string) => void
44 /** 时间线末尾、评论框之前的插槽(合并盒等页面级操作)。 */
45 beforeComposer?: ReactNode
46}
47
48/** 会话页签:评论线程 + 评审卡片 + 事件时间线,按时间归并。 */
49export function Conversation({ refObj, pull, onChanged, onOpenFile, beforeComposer }: ConversationProps) {
50 const { t } = useT()
51 const [tick, setTick] = useState(0)
52 const refresh = () => {
53 setTick((t) => t + 1)
54 onChanged()
55 }
56 const key = [refObj.space, refObj.repo, refObj.pull, tick]
57
58 const threads = useAsync(() => listCommentTree(refObj), key, undefined)
59 const events = useAsync(() => listEvents(refObj, { limit: 100 }), key, undefined)
60 const reviews = useAsync(() => listReviews(refObj), key, undefined)
61
62 const timeline = useMemo<Row[]>(() => {
63 const rows: Row[] = []
64 for (const thread of threads.data ?? []) rows.push({ kind: 'thread', at: thread.root.created_at, thread })
65 for (const review of reviews.data ?? []) rows.push({ kind: 'review', at: review.created_at, review })
66 for (const event of events.data?.items ?? []) rows.push({ kind: 'event', at: event.created_at, event })
67 return rows.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
68 }, [threads.data, reviews.data, events.data])
69
70 return (
71 <div className="space-y-4">
72 {pull.locked ? (
73 <Alert tone="info">
74 <span className="inline-flex items-center gap-1">
75 <Lock className="h-3.5 w-3.5" aria-hidden />
76 {t('pull.conv.locked')}
77 </span>
78 </Alert>
79 ) : null}
80
81 {timeline.map((row) =>
82 row.kind === 'thread' ? (
83 <ThreadCard
84 key={`t:${row.thread.root.id}`}
85 refObj={refObj}
86 thread={row.thread}
87 onChanged={refresh}
88 onOpenFile={onOpenFile}
89 />
90 ) : row.kind === 'review' ? (
91 <ReviewCard key={`r:${row.review.id}`} refObj={refObj} review={row.review} onChanged={refresh} />
92 ) : (
93 <EventRow key={`e:${row.event.id}`} event={row.event} />
94 ),
95 )}
96
97 {beforeComposer}
98
99 <Composer refObj={refObj} pull={pull} onPosted={refresh} />
100 </div>
101 )
102}
103
104/* -------------------------------------------------------------- threads */
105
106function CommentBubble({
107 refObj,
108 comment,
109 onChanged,
110}: {
111 refObj: PullRef
112 comment: PullComment
113 onChanged: () => void
114}) {
115 const { t } = useT()
116 const [editing, setEditing] = useState(false)
117 const [draft, setDraft] = useState(comment.body)
118 const [busy, setBusy] = useState(false)
119 const [error, setError] = useState<string | null>(null)
120
121 async function save() {
122 setBusy(true)
123 setError(null)
124 try {
125 await updateComment(refObj, comment.id, draft)
126 setEditing(false)
127 onChanged()
128 } catch (failure) {
129 setError(failure instanceof Error ? failure.message : t('pull.conv.saveFailed'))
130 } finally {
131 setBusy(false)
132 }
133 }
134
135 async function remove() {
136 if (!window.confirm(t('pull.conv.deleteConfirm'))) return
137 try {
138 await deleteComment(refObj, comment.id)
139 onChanged()
140 } catch {
141 /* best-effort */
142 }
143 }
144
145 return (
146 <div className="flex gap-2.5">
147 <Avatar user={comment.author} size={24} />
148 <div className="min-w-0 flex-1 px-0.5">
149 <div className="flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
150 <span className="text-[12.5px] font-semibold text-shell-ink">{userName(comment.author)}</span>
151 <span title={fmtTime(comment.created_at)}>{fmtRelative(comment.created_at)}</span>
152 {comment.updated_at && comment.updated_at !== comment.created_at ? (
153 <span>{t('pull.conv.edited')}</span>
154 ) : null}
155 <span className="ml-auto flex items-center gap-1">
156 <button
157 type="button"
158 title={t('pull.conv.edit')}
159 onClick={() => {
160 setDraft(comment.body)
161 setEditing((v) => !v)
162 }}
163 className="p-1 text-shell-ink-muted hover:text-shell-ink"
164 >
165 <Pencil className="h-3.5 w-3.5" />
166 </button>
167 <button
168 type="button"
169 title={t('pull.conv.delete')}
170 onClick={() => void remove()}
171 className="p-1 text-shell-ink-muted hover:text-[#b42318]"
172 >
173 <Trash2 className="h-3.5 w-3.5" />
174 </button>
175 </span>
176 </div>
177 {editing ? (
178 <div className="mt-1.5">
179 <textarea
180 value={draft}
181 onChange={(event) => setDraft(event.target.value)}
182 rows={3}
183 className="w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-surface px-2.5 py-1.5 text-[13px] outline-none focus:border-shell-ink"
184 />
185 {error ? <p className="mt-1 text-[12px] text-[#b42318]">{error}</p> : null}
186 <div className="mt-1.5 flex gap-2">
187 <Button className="h-7 text-[12px]" disabled={busy} onClick={() => void save()}>
188 {t('common.action.save')}
189 </Button>
190 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setEditing(false)}>
191 {t('common.action.cancel')}
192 </Button>
193 </div>
194 </div>
195 ) : (
196 <Markdown className="mt-1">{comment.body}</Markdown>
197 )}
198 </div>
199 </div>
200 )
201}
202
203function ThreadCard({
204 refObj,
205 thread,
206 onChanged,
207 onOpenFile,
208}: {
209 refObj: PullRef
210 thread: PullCommentThread
211 onChanged: () => void
212 onOpenFile: (path: string) => void
213}) {
214 const { t } = useT()
215 const [replying, setReplying] = useState(false)
216 const [draft, setDraft] = useState('')
217 const [busy, setBusy] = useState(false)
218 const root = thread.root
219 const inline = Boolean(root.path)
220
221 async function reply() {
222 if (!draft.trim()) return
223 setBusy(true)
224 try {
225 await createComment(refObj, { body: draft.trim(), parent_comment_id: root.id })
226 setDraft('')
227 setReplying(false)
228 onChanged()
229 } finally {
230 setBusy(false)
231 }
232 }
233
234 async function toggleResolve() {
235 try {
236 if (root.resolved) await unresolveComment(refObj, root.id)
237 else await resolveComment(refObj, root.id)
238 onChanged()
239 } catch {
240 /* surfaced on next refresh */
241 }
242 }
243
244 return (
245 <div className="rounded-lg border border-shell-hairline bg-shell-surface px-3 py-2.5">
246 <div className="mb-1.5 flex flex-wrap items-center gap-2 text-[12px] text-shell-ink-muted">
247 <MessageSquare className="h-3.5 w-3.5" aria-hidden />
248 <span className="font-medium text-shell-ink">{inline ? t('pull.conv.inline') : t('pull.conv.thread')}</span>
249 {inline ? (
250 <button
251 type="button"
252 onClick={() => onOpenFile(root.path ?? '')}
253 className="rounded bg-shell-active/70 px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted hover:text-shell-ink"
254 >
255 {root.path}
256 {root.line != null ? `:${root.line}` : ''}
257 </button>
258 ) : null}
259 {root.commit_sha ? <Copyable text={root.commit_sha} /> : null}
260 {inline ? (
261 <button
262 type="button"
263 onClick={() => void toggleResolve()}
264 className={`ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${root.resolved ? TONE.green : TONE.gray} hover:brightness-95`}
265 >
266 {root.resolved ? (
267 <>
268 <CheckCheck className="h-3.5 w-3.5" aria-hidden />
269 {t('pull.conv.resolved')}
270 </>
271 ) : (
272 <>
273 <Check className="h-3.5 w-3.5" aria-hidden />
274 {t('pull.conv.markResolved')}
275 </>
276 )}
277 </button>
278 ) : null}
279 </div>
280 <div className="space-y-2">
281 <CommentBubble refObj={refObj} comment={root} onChanged={onChanged} />
282 {thread.replies.map((replyComment) => (
283 <CommentBubble key={replyComment.id} refObj={refObj} comment={replyComment} onChanged={onChanged} />
284 ))}
285 </div>
286 {replying ? (
287 <div className="mt-1.5 flex gap-2">
288 <textarea
289 autoFocus
290 value={draft}
291 onChange={(event) => setDraft(event.target.value)}
292 rows={2}
293 placeholder={t('pull.conv.replyPlaceholder')}
294 className="min-w-0 flex-1 resize-y rounded-md border-2 border-shell-hairline bg-shell-field px-2.5 py-1.5 text-[13px] outline-none focus:border-shell-ink"
295 />
296 <div className="flex flex-col gap-1.5">
297 <Button className="h-8 gap-1 text-[12.5px]" disabled={busy} onClick={() => void reply()}>
298 <Send className="h-3.5 w-3.5" aria-hidden />
299 {t('pull.conv.send')}
300 </Button>
301 <Button variant="ghost" className="h-6 text-[12px]" onClick={() => setReplying(false)}>
302 {t('common.action.cancel')}
303 </Button>
304 </div>
305 </div>
306 ) : (
307 <button
308 type="button"
309 onClick={() => setReplying(true)}
310 className="mt-1.5 inline-flex items-center gap-1 text-[12px] text-shell-ink-muted hover:text-shell-ink"
311 >
312 <Reply className="h-3.5 w-3.5" aria-hidden />
313 {t('pull.conv.reply')}
314 </button>
315 )}
316 </div>
317 )
318}
319
320/* --------------------------------------------------------------- reviews */
321
322function ReviewCard({ refObj, review, onChanged }: { refObj: PullRef; review: PullReview; onChanged: () => void }) {
323 const { t, tc } = useT()
324 const [busy, setBusy] = useState(false)
325 const [full, setFull] = useState<PullReview | null>(null)
326 const shown = full ?? review
327 const tone = TONE[REVIEW_STATE_TONE[shown.state]]
328
329 async function publish(state: ReviewIntent) {
330 setBusy(true)
331 try {
332 await publishReview(refObj, review.id, { state, body: review.content ?? null })
333 onChanged()
334 } finally {
335 setBusy(false)
336 }
337 }
338
339 async function dismiss() {
340 setBusy(true)
341 try {
342 await dismissReview(refObj, review.id)
343 onChanged()
344 } finally {
345 setBusy(false)
346 }
347 }
348
349 return (
350 <div className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
351 <div className="flex flex-wrap items-center gap-2 text-[12px]">
352 <Avatar user={review.user} size={24} />
353 <span className="text-[13px] font-semibold">{userName(review.user)}</span>
354 <span className={`rounded-full px-2 py-0.5 text-[11.5px] font-medium ${tone}`}>
355 {REVIEW_STATE_LABEL[review.state]}
356 </span>
357 {review.official ? (
358 <span className="rounded bg-shell-active px-1.5 py-0.5 text-[11px] text-shell-ink-muted">
359 {t('pull.conv.official')}
360 </span>
361 ) : null}
362 <span className="text-shell-ink-muted" title={fmtTime(review.created_at)}>
363 {fmtRelative(review.created_at)}
364 </span>
365 {review.commit_sha ? <Copyable text={review.commit_sha} /> : null}
366 {review.comment_count > 0 ? (
367 <span className="text-shell-ink-muted">{tc('pull.conv.inlineCount', review.comment_count)}</span>
368 ) : null}
369 <span className="ml-auto flex items-center gap-1.5">
370 {review.state === 'pending' ? (
371 <>
372 <Button
373 variant="secondary"
374 className="h-7 text-[12px]"
375 disabled={busy}
376 onClick={() => void publish('approve')}
377 >
378 {t('pull.conv.publishApprove')}
379 </Button>
380 <Button
381 variant="secondary"
382 className="h-7 text-[12px]"
383 disabled={busy}
384 onClick={() => void publish('request_changes')}
385 >
386 {t('pull.conv.publishRequestChanges')}
387 </Button>
388 <Button
389 variant="ghost"
390 className="h-7 text-[12px]"
391 disabled={busy}
392 onClick={() => void publish('comment')}
393 >
394 {t('pull.conv.publishComment')}
395 </Button>
396 </>
397 ) : review.state === 'approved' || review.state === 'changes_requested' ? (
398 <button
399 type="button"
400 onClick={() => void dismiss()}
401 className="inline-flex items-center gap-1 rounded px-1.5 py-1 text-[12px] text-shell-ink-muted hover:text-shell-ink"
402 >
403 <RotateCcw className="h-3.5 w-3.5" aria-hidden />
404 {t('pull.conv.dismiss')}
405 </button>
406 ) : null}
407 </span>
408 </div>
409 {shown.content ? <Markdown className="mt-2">{shown.content}</Markdown> : null}
410 {!full && review.comment_count > 0 ? (
411 <button
412 type="button"
413 onClick={() =>
414 void getReview(refObj, review.id)
415 .then(setFull)
416 .catch(() => setFull(review))
417 }
418 className="mt-1.5 text-[12px] text-shell-ink-muted underline underline-offset-2 hover:text-shell-ink"
419 >
420 {t('pull.conv.loadDetails', { count: review.comment_count })}
421 </button>
422 ) : null}
423 </div>
424 )
425}
426
427/* ---------------------------------------------------------------- events */
428
429function EventRow({ event }: { event: PullEvent }) {
430 return (
431 <div className="flex flex-wrap items-center gap-2 px-1 text-[12.5px] text-shell-ink-muted">
432 <Avatar user={event.actor} size={18} />
433 <span>
434 <span className="font-medium text-shell-ink">{userName(event.actor)}</span> {eventLabel(event.event_type)}
435 </span>
436 {event.commit_sha ? <Copyable text={event.commit_sha} /> : null}
437 <span title={fmtTime(event.created_at)}>{fmtRelative(event.created_at)}</span>
438 </div>
439 )
440}
441
442/* --------------------------------------------------------------- composer */
443
444type ReviewMode = Exclude<ReviewIntent, 'pending'> | 'comment'
445
446const REVIEW_MODE_KEYS: Record<ReviewMode, MessageKey> = {
447 comment: 'pull.conv.mode.comment',
448 approve: 'pull.conv.mode.approve',
449 request_changes: 'pull.conv.mode.requestChanges',
450}
451
452const REVIEW_MODES: ReviewMode[] = ['comment', 'approve', 'request_changes']
453
454function Composer({ refObj, pull, onPosted }: { refObj: PullRef; pull: Pull; onPosted: () => void }) {
455 const { t } = useT()
456 const [mode, setMode] = useState<ReviewIntent | 'comment'>('comment')
457 const [body, setBody] = useState('')
458 const [busy, setBusy] = useState(false)
459 const [error, setError] = useState<string | null>(null)
460
461 async function post() {
462 if (!body.trim()) {
463 setError(t('pull.conv.empty'))
464 return
465 }
466 setBusy(true)
467 setError(null)
468 try {
469 if (mode === 'comment') await createComment(refObj, { body: body.trim() })
470 else await submitReview(refObj, { state: mode, body: body.trim() })
471 setBody('')
472 onPosted()
473 } catch (failure) {
474 setError(failure instanceof Error ? failure.message : t('pull.conv.submitFailed'))
475 } finally {
476 setBusy(false)
477 }
478 }
479
480 const closed = pull.state !== 'open'
481 return (
482 <SideSection
483 title={closed ? t('pull.conv.append') : t('pull.conv.write')}
484 action={
485 <div className="flex items-center gap-0.5 rounded-md bg-shell-field p-0.5">
486 {REVIEW_MODES.map((choice) => (
487 <button
488 key={choice}
489 type="button"
490 aria-pressed={mode === choice}
491 disabled={choice !== 'comment' && (pull.is_draft || closed)}
492 onClick={() => setMode(choice)}
493 className={`rounded px-2 py-0.5 text-[12px] transition-colors disabled:opacity-40 ${
494 mode === choice
495 ? 'bg-shell-active font-semibold text-shell-ink'
496 : 'text-shell-ink-muted hover:text-shell-ink'
497 }`}
498 >
499 {t(REVIEW_MODE_KEYS[choice])}
500 </button>
501 ))}
502 </div>
503 }
504 >
505 <textarea
506 value={body}
507 onChange={(event) => setBody(event.target.value)}
508 rows={4}
509 placeholder={mode === 'comment' ? t('pull.conv.commentPlaceholder') : t('pull.conv.reviewPlaceholder')}
510 className="w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 text-[13px] leading-6 outline-none focus:border-shell-ink"
511 />
512 {mode !== 'comment' && pull.is_draft ? (
513 <p className="mt-1 text-[12px] text-[#8a6100]">{t('pull.conv.draftReviewHint')}</p>
514 ) : null}
515 {error ? <p className="mt-1 text-[12px] text-[#b42318]">{error}</p> : null}
516 <div className="mt-2 flex items-center gap-2">
517 <Button className="h-8 gap-1 text-[12.5px]" disabled={busy} onClick={() => void post()}>
518 <Send className="h-3.5 w-3.5" aria-hidden />
519 {busy ? t('pull.conv.submitting') : mode === 'comment' ? t('pull.conv.post') : t('pull.conv.submitReview')}
520 </Button>
521 {body ? (
522 <Button variant="ghost" className="h-8 text-[12px]" onClick={() => setBody('')}>
523 {t('pull.conv.clear')}
524 </Button>
525 ) : null}
526 </div>
527 </SideSection>
528 )
529}
Added view/app/src/domains/pulls/ui/files-tab.tsx View view/app/src/domains/pulls/ui/files-tab.tsx
@@ -0,0 +1,316 @@
1import { ErrorPanel, modalTextareaClass, PanelNote } from '@app/components'
2import type { ReviewIntent } from '@app/domains/pulls/model'
3import {
4 createComment,
5 type InlineDraft,
6 listCommentTree,
7 listPullFiles,
8 type PullRef,
9 submitReview,
10} from '@app/domains/pulls/model'
11import type { MessageKey } from '@app/i18n'
12import { t as tr, useT } from '@app/i18n'
13import { useAsync } from '@app/lib'
14import { Button } from '@app/ui'
15import { useEffect, useMemo, useState } from 'react'
16import { type AnchoredComment, anchorKey, changedFileToDiff, type InlineAnchor } from '../model/meta'
17import { Copyable, InlineDiffCard } from './parts'
18
19export interface FilesTabProps {
20 refObj: PullRef
21 headSha: string
22 /** File path to expand/scroll to when jumping from an inline comment. */
23 focusPath: string
24 /** One-based line to scroll to inside `focusPath`; 0 disables scrolling. */
25 focusLine?: number
26 onChanged: () => void
27}
28
29/** 变更文件页签:逐文件 diff + 行内评论 + 待提交评审草稿。 */
30export function FilesTab({ refObj, headSha, focusPath, focusLine = 0, onChanged }: FilesTabProps) {
31 const { t, tc } = useT()
32 const [tick, setTick] = useState(0)
33 const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
34 const [draftAnchor, setDraftAnchor] = useState<InlineAnchor | null>(null)
35 const [draftBody, setDraftBody] = useState('')
36 const [pending, setPending] = useState<InlineDraft[]>([])
37 const [reviewBody, setReviewBody] = useState('')
38 const [busy, setBusy] = useState(false)
39 const [error, setError] = useState<string | null>(null)
40
41 const files = useAsync(() => listPullFiles(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
42 const threads = useAsync(() => listCommentTree(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
43
44 const commentsByLine = useMemo(() => {
45 const map = new Map<string, AnchoredComment[]>()
46 for (const thread of threads.data ?? []) {
47 const root = thread.root
48 if (!root.path || root.line == null) continue
49 // 评论自带 side(left/right),必须按实际值归位,否则删除行上的评论会落到新增侧。
50 const key = anchorKey(root.path, root.line, root.side === 'left' ? 'left' : 'right')
51 const list = map.get(key) ?? []
52 list.push({ id: root.id, body: root.body }, ...thread.replies.map((r) => ({ id: r.id, body: r.body })))
53 map.set(key, list)
54 }
55 return map
56 }, [threads.data])
57
58 const rows = useMemo(() => (files.data ?? []).map((file) => changedFileToDiff(file)), [files.data])
59
60 // 从行内评论跳转过来时,滚动到目标行(两侧都试,命中即止)。
61 useEffect(() => {
62 if (!focusLine || !focusPath) return
63 const id = `diff-right-${encodeURIComponent(focusPath)}-${focusLine}`
64 const alt = `diff-left-${encodeURIComponent(focusPath)}-${focusLine}`
65 const target = document.getElementById(id) ?? document.getElementById(alt)
66 if (target) {
67 target.scrollIntoView({ block: 'center' })
68 target.classList.add('bg-[rgba(240,178,50,0.16)]')
69 const timer = window.setTimeout(() => target.classList.remove('bg-[rgba(240,178,50,0.16)]'), 2000)
70 return () => window.clearTimeout(timer)
71 }
72 }, [focusLine, focusPath, rows.length])
73
74 const totalAdd = rows.reduce((sum, row) => sum + row.additions, 0)
75 const totalDel = rows.reduce((sum, row) => sum + row.deletions, 0)
76 const failure = files.error as { code?: number; message?: string } | undefined
77
78 async function postLineComment() {
79 if (!draftAnchor || !draftBody.trim()) return
80 setBusy(true)
81 setError(null)
82 try {
83 await createComment(refObj, {
84 body: draftBody.trim(),
85 path: draftAnchor.path,
86 line: draftAnchor.line,
87 side: draftAnchor.side === 'left' ? 'left' : 'right',
88 commit_sha: headSha || null,
89 })
90 setDraftAnchor(null)
91 setDraftBody('')
92 setTick((t) => t + 1)
93 onChanged()
94 } catch (f) {
95 setError(f instanceof Error ? f.message : t('pull.files.commentFailed'))
96 } finally {
97 setBusy(false)
98 }
99 }
100
101 function queueAsReviewDraft() {
102 if (!draftAnchor || !draftBody.trim()) return
103 setPending((prev) => [
104 ...prev,
105 { path: draftAnchor.path, line: draftAnchor.line, side: draftAnchor.side, body: draftBody.trim() },
106 ])
107 setDraftAnchor(null)
108 setDraftBody('')
109 }
110
111 async function submitPendingReview(state: ReviewIntent) {
112 setBusy(true)
113 setError(null)
114 try {
115 await submitReview(refObj, { state, body: reviewBody || null, comments: pending })
116 setPending([])
117 setReviewBody('')
118 setTick((t) => t + 1)
119 onChanged()
120 } catch (f) {
121 setError(f instanceof Error ? f.message : t('pull.files.reviewFailed'))
122 } finally {
123 setBusy(false)
124 }
125 }
126
127 if (files.loading && !files.data) return <PanelNote text={t('pull.files.loading')} />
128 if (failure)
129 return (
130 <ErrorPanel
131 status={failure.code ?? 0}
132 what={t('pull.files.errorFiles')}
133 note={failure.message}
134 onRetry={() => setTick((tick) => tick + 1)}
135 />
136 )
137 if (!rows.length) return <PanelNote text={t('pull.files.empty')} />
138
139 return (
140 <div className="space-y-3">
141 <p className="text-[12.5px] text-shell-ink-muted">
142 {tc('pull.files.summary', rows.length)} · <span className="text-[#1a7f37]">+{totalAdd}</span>{' '}
143 <span className="text-[#b42318]">−{totalDel}</span>
144 {headSha ? (
145 <>
146 {' '}
147 · {t('pull.files.compareVersion')} <Copyable text={headSha} />
148 </>
149 ) : null}
150 </p>
151
152 {rows.map((diff) => {
153 const open = !(collapsed[diff.path] ?? false) || diff.path === focusPath
154 return (
155 <section
156 key={diff.path}
157 id={`file-${encodeURIComponent(diff.path)}`}
158 className="group/diff overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface"
159 >
160 <button
161 type="button"
162 aria-expanded={open}
163 onClick={() => setCollapsed((prev) => ({ ...prev, [diff.path]: open }))}
164 className="flex w-full items-center gap-2 border-b border-shell-hairline bg-shell-field px-3 py-2 text-left transition-colors hover:brightness-95"
165 >
166 <span className={`shrink-0 font-mono text-[11px] font-semibold ${statusTone(diff.status)}`}>
167 {statusLabel(diff.status)}
168 </span>
169 <span className="min-w-0 flex-1 truncate font-mono text-[12.5px]" title={diff.path}>
170 {diff.oldPath && diff.oldPath !== diff.path ? `${diff.oldPath} → ${diff.path}` : diff.path}
171 </span>
172 <span className="shrink-0 font-mono text-[11.5px]">
173 <span className="text-[#1a7f37]">+{diff.additions}</span>{' '}
174 <span className="text-[#b42318]">−{diff.deletions}</span>
175 </span>
176 </button>
177 {open ? (
178 <InlineDiffCard
179 file={diff}
180 commentsByLine={commentsByLine}
181 onRequestComment={(anchor) => {
182 setDraftAnchor(anchor)
183 setDraftBody('')
184 }}
185 renderDraft={(anchor) =>
186 draftAnchor &&
187 draftAnchor.path === anchor.path &&
188 draftAnchor.line === anchor.line &&
189 draftAnchor.side === anchor.side ? (
190 <div className="space-y-1.5">
191 <textarea
192 autoFocus
193 value={draftBody}
194 onChange={(event) => setDraftBody(event.target.value)}
195 rows={3}
196 placeholder={t('pull.files.linePlaceholder', {
197 path: anchor.path,
198 line: anchor.line,
199 side: anchor.side === 'left' ? t('pull.files.sideOld') : t('pull.files.sideNew'),
200 })}
201 className={modalTextareaClass}
202 />
203 {error ? <p className="text-[12px] text-[#b42318]">{error}</p> : null}
204 <div className="flex items-center gap-2">
205 <Button className="h-7 text-[12px]" disabled={busy} onClick={() => void postLineComment()}>
206 {t('pull.files.postLine')}
207 </Button>
208 <Button
209 variant="secondary"
210 className="h-7 text-[12px]"
211 disabled={busy}
212 onClick={queueAsReviewDraft}
213 >
214 {t('pull.files.queueReview')}
215 </Button>
216 <Button variant="ghost" className="h-7 text-[12px]" onClick={() => setDraftAnchor(null)}>
217 {t('common.action.cancel')}
218 </Button>
219 </div>
220 </div>
221 ) : null
222 }
223 />
224 ) : null}
225 </section>
226 )
227 })}
228
229 {pending.length > 0 ? (
230 <div className="sticky bottom-3 rounded-xl border-2 border-shell-tile bg-shell-surface p-4 shadow-lg">
231 <h4 className="mb-2 text-[13px] font-semibold">{t('pull.files.pendingTitle', { count: pending.length })}</h4>
232 <ul className="mb-2 max-h-32 space-y-1 overflow-y-auto text-[12px] text-shell-ink-muted">
233 {pending.map((draft, index) => (
234 <li key={index} className="flex items-center gap-2">
235 <span className="min-w-0 flex-1 truncate font-mono">
236 {draft.path}:{draft.line}
237 </span>
238 <span className="min-w-0 max-w-[50%] truncate">{draft.body}</span>
239 <button
240 type="button"
241 aria-label={t('pull.files.removeDraft')}
242 onClick={() => setPending((prev) => prev.filter((_, i) => i !== index))}
243 className="text-shell-ink-muted hover:text-[#b42318]"
244 >
245 ×
246 </button>
247 </li>
248 ))}
249 </ul>
250 <textarea
251 value={reviewBody}
252 onChange={(event) => setReviewBody(event.target.value)}
253 rows={2}
254 placeholder={t('pull.files.reviewSummaryPlaceholder')}
255 className={modalTextareaClass}
256 />
257 <div className="mt-2 flex items-center gap-2">
258 <Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('approve')}>
259 {t('pull.files.approve')}
260 </Button>
261 <Button
262 variant="secondary"
263 className="h-8 text-[12.5px]"
264 disabled={busy}
265 onClick={() => void submitPendingReview('request_changes')}
266 >
267 {t('pull.files.requestChanges')}
268 </Button>
269 <Button
270 variant="secondary"
271 className="h-8 text-[12.5px]"
272 disabled={busy}
273 onClick={() => void submitPendingReview('comment')}
274 >
275 {t('pull.files.commentOnly')}
276 </Button>
277 <Button
278 variant="secondary"
279 className="h-8 text-[12.5px]"
280 disabled={busy}
281 onClick={() => void submitPendingReview('pending')}
282 >
283 {t('pull.files.saveAsPending')}
284 </Button>
285 <Button variant="ghost" className="h-8 text-[12px]" onClick={() => setPending([])}>
286 {t('pull.files.discard')}
287 </Button>
288 </div>
289 </div>
290 ) : null}
291 </div>
292 )
293}
294
295function statusLabel(status: string): string {
296 const key = STATUS_KEYS[status]
297 return key ? tr(key) : tr('pull.files.status.modified')
298}
299
300const STATUS_KEYS: Record<string, MessageKey> = {
301 added: 'pull.files.status.added',
302 deleted: 'pull.files.status.deleted',
303 renamed: 'pull.files.status.renamed',
304 copied: 'pull.files.status.copied',
305 binary: 'pull.files.status.binary',
306 modified: 'pull.files.status.modified',
307}
308
309const statusTone = (status: string) =>
310 status === 'added'
311 ? 'text-[#1a7f37]'
312 : status === 'deleted'
313 ? 'text-[#b42318]'
314 : status === 'binary'
315 ? 'text-[#8a6100]'
316 : 'text-shell-ink-muted'
Added view/app/src/domains/pulls/ui/parts.tsx View view/app/src/domains/pulls/ui/parts.tsx
@@ -0,0 +1,331 @@
1import { Markdown } from '@app/components'
2import type { Pull, UserDto } from '@app/domains/pulls/model'
3import { type DiffFile, type DiffLine, parseHunks } from '@app/domains/repo/model'
4import { InitialAvatar } from '@app/domains/shared'
5import type { MessageKey } from '@app/i18n'
6import { t as tr, useT } from '@app/i18n'
7import { shortSha } from '@app/lib'
8import { ChevronDown, GitMerge, MessageSquareText, Search, Tag as TagIcon } from 'lucide-react'
9import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react'
10import { type AnchoredComment, anchorKey, type InlineAnchor, PULL_STATE_LABEL, TONE, userName } from '../model/meta'
11
12export function Avatar({ user, size = 26 }: { user?: UserDto | null; size?: number }) {
13 return <InitialAvatar name={userName(user)} url={user?.avatar_url || null} size={size} />
14}
15
16export function PullStateBadge({ pull }: { pull: Pull }) {
17 if (pull.is_draft) {
18 return (
19 <span
20 className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${TONE.gray}`}
21 >
22 {tr('pull.badge.draft')}
23 </span>
24 )
25 }
26 const tone = pull.state === 'open' ? TONE.green : pull.state === 'merged' ? TONE.violet : TONE.gray
27 return (
28 <span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${tone}`}>
29 {pull.state === 'merged' ? <GitMerge className="h-3 w-3" aria-hidden /> : null}
30 {PULL_STATE_LABEL[pull.state]}
31 {pull.merged_at ? ' ✓' : ''}
32 </span>
33 )
34}
35
36const MERGEABLE_KEYS: Record<string, { key: MessageKey; tone: keyof typeof TONE }> = {
37 clean: { key: 'pull.mergeable.clean', tone: 'green' },
38 blocked: { key: 'pull.mergeable.blocked', tone: 'amber' },
39 dirty: { key: 'pull.mergeable.dirty', tone: 'red' },
40 unstable: { key: 'pull.mergeable.unstable', tone: 'amber' },
41 has_hooks: { key: 'pull.mergeable.hasHooks', tone: 'gray' },
42 unknown: { key: 'pull.mergeable.unknown', tone: 'gray' },
43}
44
45export function MergeableChip({ state }: { state?: string | null }) {
46 const { t } = useT()
47 if (!state) return null
48 const entry = MERGEABLE_KEYS[state]
49 const hit = entry ? { text: t(entry.key), tone: entry.tone } : { text: state, tone: 'gray' as const }
50 return <span className={`rounded px-1.5 py-0.5 text-[11.5px] font-medium ${TONE[hit.tone]}`}>{hit.text}</span>
51}
52
53export function LabelPill({ name, color, onRemove }: { name: string; color?: string | null; onRemove?: () => void }) {
54 const { t } = useT()
55 return (
56 <span
57 className="inline-flex items-center gap-1 rounded-full border border-shell-hairline px-2 py-0.5 text-[11.5px] font-medium text-shell-ink"
58 style={color ? { backgroundColor: `${color}22`, borderColor: color } : undefined}
59 >
60 <TagIcon className="h-3 w-3" aria-hidden />
61 {name}
62 {onRemove ? (
63 <button
64 type="button"
65 aria-label={t('issue.board.removeLabel', { name })}
66 onClick={onRemove}
67 className="ml-0.5 text-shell-ink-muted hover:text-shell-ink"
68 >
69 ×
70 </button>
71 ) : null}
72 </span>
73 )
74}
75
76export function SideSection({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
77 return (
78 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
79 <div className="mb-2 flex items-center justify-between gap-2">
80 <h4 className="font-mono text-[10.5px] font-semibold uppercase tracking-[1.2px] text-shell-ink-muted">
81 {title}
82 </h4>
83 {action}
84 </div>
85 {children}
86 </section>
87 )
88}
89
90/* ------------------------------------------------------------------ diff */
91
92/**
93 * One changed file of the files tab: the commit-view look (unified hunk table)
94 * plus per-line affordances — existing inline comments are pinned under their
95 * line and a hover button starts a new draft anchored to it.
96 */
97export function InlineDiffCard({
98 file,
99 commentsByLine,
100 onRequestComment,
101 renderDraft,
102}: {
103 file: DiffFile
104 commentsByLine: Map<string, AnchoredComment[]>
105 onRequestComment?: (anchor: InlineAnchor) => void
106 renderDraft?: (anchor: InlineAnchor) => ReactNode
107}) {
108 const { hunks, truncated } = parseHunks(file.raw)
109 const canComment = Boolean(onRequestComment)
110 return (
111 <div className="overflow-x-auto">
112 {file.status === 'binary' ? (
113 <p className="px-3 py-2 text-[12.5px] text-shell-ink-muted">{tr('pull.parts.binaryDiff')}</p>
114 ) : null}
115 {file.status !== 'binary' && hunks.length === 0 ? (
116 <p className="px-3 py-2 text-[12.5px] text-shell-ink-muted">{tr('pull.parts.metaOnly')}</p>
117 ) : null}
118 <table className="w-full border-collapse font-mono text-[12px] leading-5">
119 <tbody>
120 {hunks.map((hunk) => (
121 <Fragment key={hunk.header}>
122 <tr>
123 <td colSpan={4} className="bg-shell-field px-3 py-1 text-[11.5px] text-shell-ink-muted">
124 {hunk.header}
125 </td>
126 </tr>
127 {hunk.lines.map((line, index) => {
128 const side: 'left' | 'right' = line.kind === 'del' ? 'left' : 'right'
129 const lineNo = side === 'left' ? line.oldNo : line.newNo
130 const key = lineNo != null ? anchorKey(file.path, lineNo, side) : null
131 const comments = key ? (commentsByLine.get(key) ?? []) : []
132 const draft = key && lineNo != null ? renderDraft?.({ path: file.path, line: lineNo, side }) : null
133 return (
134 <Fragment key={`${hunk.header}:${index}`}>
135 <tr
136 id={lineNo != null ? `diff-${side}-${encodeURIComponent(file.path)}-${lineNo}` : undefined}
137 className={lineTone(line)}
138 >
139 <td className="w-10 shrink-0 select-none border-r border-shell-hairline px-2 text-right text-[11px] text-shell-ink-muted">
140 {line.oldNo ?? ''}
141 </td>
142 <td className="w-10 shrink-0 select-none border-r border-shell-hairline px-2 text-right text-[11px] text-shell-ink-muted">
143 {line.newNo ?? ''}
144 </td>
145 <td className="w-6 select-none px-1 text-center text-[11px] text-shell-ink-muted">
146 {canComment && lineNo != null ? (
147 <button
148 type="button"
149 title={tr('pull.parts.addComment')}
150 onClick={() => onRequestComment?.({ path: file.path, line: lineNo, side })}
151 className="rounded bg-shell-active px-1 text-shell-ink-muted opacity-0 transition-opacity hover:text-shell-ink focus:opacity-100 group-hover/diff:opacity-100"
152 >
153 <MessageSquareText className="h-3 w-3" aria-hidden />
154 </button>
155 ) : line.kind === 'add' ? (
156 '+'
157 ) : line.kind === 'del' ? (
158 '−'
159 ) : (
160 ''
161 )}
162 </td>
163 <td className="whitespace-pre-wrap break-all px-2 py-0.5">{line.text || ' '}</td>
164 </tr>
165 {comments.map((comment) => (
166 <tr key={comment.id}>
167 <td colSpan={4} className="border-b border-shell-hairline bg-shell-field/60 px-3 py-1.5">
168 <Markdown compact className="font-sans">
169 {comment.body}
170 </Markdown>
171 </td>
172 </tr>
173 ))}
174 {draft ? (
175 <tr>
176 <td colSpan={4} className="border-b border-shell-hairline bg-shell-field/60 px-3 py-2">
177 <div className="font-sans">{draft}</div>
178 </td>
179 </tr>
180 ) : null}
181 </Fragment>
182 )
183 })}
184 </Fragment>
185 ))}
186 </tbody>
187 </table>
188 {truncated ? (
189 <p className="border-t border-shell-hairline px-3 py-2 text-[12px] text-[#8a6100]">
190 {tr('pull.parts.truncated')}
191 </p>
192 ) : null}
193 </div>
194 )
195}
196
197const lineTone = (line: DiffLine) =>
198 line.kind === 'add' ? 'bg-[rgba(35,165,90,0.12)]' : line.kind === 'del' ? 'bg-[rgba(242,63,67,0.10)]' : ''
199
200export function Copyable({ text }: { text: string }) {
201 return (
202 <span
203 role="button"
204 tabIndex={0}
205 title={text}
206 onClick={() => void navigator.clipboard?.writeText(text).catch(() => undefined)}
207 onKeyDown={(event) => {
208 if (event.key === 'Enter') void navigator.clipboard?.writeText(text).catch(() => undefined)
209 }}
210 className="cursor-copy rounded bg-shell-active/70 px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted transition-colors hover:text-shell-ink"
211 >
212 {shortSha(text, 8)}
213 </span>
214 )
215}
216
217export interface SearchSelectOption {
218 value: string
219 label: string
220 /** 右侧灰字(用户名等补充信息)。 */
221 hint?: string
222 /** 左侧色点(标签颜色)。 */
223 color?: string | null
224 /** 左侧自定义图形(头像等)。 */
225 icon?: ReactNode
226}
227
228/**
229 * 可搜索下拉:触发按钮 + 弹出搜索框 + 过滤后的选项列表。
230 * 侧栏的评审人/受理人/标签只能从这里选,避免手输拼错或写入无效值。
231 */
232export function SearchSelect({
233 placeholder,
234 options,
235 onSelect,
236 disabled = false,
237 emptyText,
238}: {
239 placeholder: string
240 options: SearchSelectOption[]
241 onSelect: (value: string) => void
242 disabled?: boolean
243 emptyText: string
244}) {
245 const box = useRef<HTMLDivElement | null>(null)
246 const [open, setOpen] = useState(false)
247 const [query, setQuery] = useState('')
248 const trimmed = query.trim().toLowerCase()
249 const filtered = trimmed
250 ? options.filter((option) => `${option.label} ${option.hint ?? ''}`.toLowerCase().includes(trimmed))
251 : options
252
253 useEffect(() => {
254 if (!open) return
255 const onDown = (event: MouseEvent) => {
256 if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
257 }
258 const onKey = (event: KeyboardEvent) => {
259 if (event.key === 'Escape') setOpen(false)
260 }
261 window.addEventListener('mousedown', onDown)
262 window.addEventListener('keydown', onKey)
263 return () => {
264 window.removeEventListener('mousedown', onDown)
265 window.removeEventListener('keydown', onKey)
266 }
267 }, [open])
268
269 return (
270 <div ref={box} className="relative mt-2">
271 <button
272 type="button"
273 aria-expanded={open}
274 aria-haspopup="listbox"
275 disabled={disabled}
276 onClick={() => {
277 setQuery('')
278 setOpen((value) => !value)
279 }}
280 className="flex h-7 w-full items-center gap-1.5 rounded-md border border-shell-hairline bg-shell-field px-2 text-left text-[12px] text-shell-ink-muted outline-none transition-colors hover:text-shell-ink focus-visible:border-shell-tile disabled:pointer-events-none disabled:opacity-50"
281 >
282 <Search className="h-3 w-3 shrink-0 opacity-70" aria-hidden />
283 <span className="min-w-0 flex-1 truncate">{placeholder}</span>
284 <ChevronDown className="h-3 w-3 shrink-0 opacity-70" aria-hidden />
285 </button>
286 {open ? (
287 <div className="absolute left-0 right-0 top-full z-30 mt-1 rounded-lg border border-shell-hairline bg-shell-surface shadow-lg">
288 <input
289 autoFocus
290 value={query}
291 onChange={(event) => setQuery(event.target.value)}
292 placeholder={placeholder}
293 className="h-7 w-full rounded-t-lg border-b border-shell-hairline bg-shell-field px-2 text-[12px] outline-none"
294 />
295 <div role="listbox" className="max-h-44 overflow-y-auto py-1">
296 {filtered.length === 0 ? (
297 <p className="px-2 py-1.5 text-[12px] text-shell-ink-muted">{emptyText}</p>
298 ) : (
299 filtered.map((option) => (
300 <button
301 key={option.value}
302 type="button"
303 role="option"
304 aria-selected={false}
305 onClick={() => {
306 setOpen(false)
307 onSelect(option.value)
308 }}
309 className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-[12.5px] text-shell-ink transition-colors hover:bg-shell-active"
310 >
311 {option.icon ??
312 (option.color ? (
313 <span
314 className="h-2 w-2 shrink-0 rounded-full"
315 style={{ backgroundColor: option.color }}
316 aria-hidden
317 />
318 ) : null)}
319 <span className="min-w-0 flex-1 truncate">{option.label}</span>
320 {option.hint ? (
321 <span className="shrink-0 text-[11.5px] text-shell-ink-muted">{option.hint}</span>
322 ) : null}
323 </button>
324 ))
325 )}
326 </div>
327 </div>
328 ) : null}
329 </div>
330 )
331}
Added view/app/src/domains/pulls/ui/pull-detail.tsx View view/app/src/domains/pulls/ui/pull-detail.tsx
@@ -0,0 +1,959 @@
1import {
2 Alert,
3 ErrorPanel,
4 Modal,
5 modalInputClass,
6 modalLabelClass,
7 modalTextareaClass,
8 PanelNote,
9} from '@app/components'
10import { listSpaceMembers } from '@app/domains/issues/model'
11import type { MergeMethod, Pull, PullGate, PullRef, ReviewerState } from '@app/domains/pulls/model'
12import {
13 addAssignee,
14 attachLabel,
15 closePull,
16 deletePull,
17 detachLabel,
18 getPull,
19 getPullGate,
20 listAssignees,
21 listPullLabelOptions,
22 listPullLabels,
23 listRequestedReviewers,
24 lockPull,
25 mergePull,
26 pullPatchUrl,
27 refreshPullSummary,
28 removeAssignee,
29 removeRequestedReviewer,
30 reopenPull,
31 requestReviewers,
32 unlockPull,
33 updateBranchPull,
34 updatePull,
35} from '@app/domains/pulls/model'
36import { fetchConflictDetail } from '@app/domains/repo/model'
37import type { MessageKey } from '@app/i18n'
38import { t as tr, useT } from '@app/i18n'
39import { fmtRelative, fmtTime, repoKey, useAsync } from '@app/lib'
40import { Button } from '@app/ui'
41import {
42 Download,
43 FileCode2,
44 GitMerge,
45 ListChecks,
46 Loader2,
47 MessagesSquare,
48 Pencil,
49 RotateCcw,
50 ShieldCheck,
51 Unlock,
52 X,
53} from 'lucide-react'
54import { useState } from 'react'
55import { MERGE_METHOD_LABEL, MERGE_METHODS, REVIEWER_STATE_LABEL, TONE, userName } from '../model/meta'
56import { ChecksTab } from './checks-tab'
57import { CommitsTab } from './commits-tab'
58import { Conversation } from './conversation'
59import { FilesTab } from './files-tab'
60import { Avatar, MergeableChip, PullStateBadge, SearchSelect, SideSection } from './parts'
61
62export type PullSubTab = 'conversation' | 'files' | 'commits' | 'checks'
63
64export interface PullDetailProps {
65 space: string
66 repo: string
67 pullRef: string
68 /** Repo write access (space owner / repo collaborator server-side). */
69 canWrite: boolean
70 ptab: PullSubTab
71 onPtabChange: (next: PullSubTab) => void
72 onBackToList: () => void
73 onOpenCommit: (sha: string) => void
74}
75
76const SUB_TABS: Array<{ id: PullSubTab; icon: typeof MessagesSquare }> = [
77 { id: 'conversation', icon: MessagesSquare },
78 { id: 'files', icon: FileCode2 },
79 { id: 'commits', icon: GitMerge },
80 { id: 'checks', icon: ListChecks },
81]
82
83const SUB_TAB_KEYS: Record<PullSubTab, MessageKey> = {
84 conversation: 'pull.detail.tab.conversation',
85 files: 'pull.tabs.files',
86 commits: 'pull.tabs.commits',
87 checks: 'pull.detail.tab.checks',
88}
89
90/** PR 详情页:头部/操作 + 子页签 + 侧栏。 */
91export function PullDetail({
92 space,
93 repo,
94 pullRef,
95 canWrite,
96 ptab,
97 onPtabChange,
98 onBackToList,
99 onOpenCommit,
100}: PullDetailProps) {
101 const { t } = useT()
102 const [tick, setTick] = useState(0)
103 const [focusFile, setFocusFile] = useState('')
104 /** 从文件变更跳转过来时要高亮的行号(0 表示不高亮)。 */
105 const [focusLine] = useState(0)
106 const [notice, setNotice] = useState<string | null>(null)
107 const [error, setError] = useState<string | null>(null)
108 const refObj: PullRef = { space, repo, pull: pullRef }
109
110 const pullState = useAsync(() => getPull(refObj), [space, repo, pullRef, tick], {
111 cacheKey: repoKey(space, repo, `pull:${pullRef}:${tick}`),
112 })
113 const gateState = useAsync(() => getPullGate(refObj), [space, repo, pullRef, tick])
114 const pull = pullState.data
115 const gate = gateState.data
116 const failure = pullState.error as { code?: number; message?: string } | undefined
117
118 const refresh = () => setTick((t) => t + 1)
119 async function run(label: string, fn: () => Promise<unknown>) {
120 setNotice(null)
121 setError(null)
122 try {
123 await fn()
124 setNotice(t('pull.detail.complete', { label }))
125 refresh()
126 } catch (f) {
127 setError(
128 t('pull.detail.actionFailed', { label, reason: f instanceof Error ? f.message : t('common.state.unknown') }),
129 )
130 }
131 }
132
133 if (pullState.loading && !pull) return <PanelNote text={t('pull.detail.loading')} />
134 if (!pull) {
135 return failure ? (
136 <ErrorPanel
137 status={failure.code ?? 0}
138 what={t('pull.detail.errorPull')}
139 note={failure.message}
140 onRetry={refresh}
141 />
142 ) : (
143 <PanelNote text={t('pull.detail.notFound')} />
144 )
145 }
146
147 return (
148 <div className="flex flex-col gap-4 lg:flex-row lg:items-start">
149 <div className="min-w-0 flex-1">
150 <div className="mb-2 flex flex-wrap items-center gap-2 text-[12.5px] text-shell-ink-muted">
151 <button type="button" onClick={onBackToList} className="hover:underline">
152 {t('pull.detail.crumb')}
153 </button>
154 <span aria-hidden>/</span>
155 <span className="font-mono">#{pull.number}</span>
156 </div>
157
158 <div className="mb-2 flex flex-wrap items-start gap-3">
159 <h2 className="min-w-0 flex-1 text-[20px] font-semibold leading-7 break-words">{pull.title}</h2>
160 <PullStateBadge pull={pull} />
161 </div>
162 <p className="mb-3 text-[12.5px] text-shell-ink-muted">
163 <span className="font-medium text-shell-ink">{userName(pull.author)}</span>{' '}
164 <code className="rounded bg-shell-active/70 px-1 py-0.5 font-mono text-[11.5px]">
165 {t('pull.detail.requestsMerge', { head: pull.head.branch, base: pull.base.branch })}
166 </code>
167 <span className="mx-1.5">·</span>
168 {t('pull.detail.updatedAt', { time: fmtRelative(pull.updated_at ?? pull.created_at) })}
169 <MergeableChip state={pull.mergeable_state} />
170 {pull.locked ? (
171 <span className={`ml-2 rounded px-1.5 py-0.5 text-[11.5px] ${TONE.amber}`}>{t('pull.detail.locked')}</span>
172 ) : null}
173 {pull.auto_merge ? (
174 <span className={`ml-2 rounded px-1.5 py-0.5 text-[11.5px] ${TONE.green}`}>
175 {t('pull.detail.autoMergeOn')}
176 </span>
177 ) : null}
178 </p>
179
180 {notice ? <Alert tone="success">{notice}</Alert> : null}
181 {error ? <Alert tone="error">{error}</Alert> : null}
182
183 <div className="mb-3 flex flex-wrap items-center gap-1.5 border-b border-shell-hairline pb-3">
184 {SUB_TABS.map((tab) => {
185 const Icon = tab.icon
186 return (
187 <button
188 key={tab.id}
189 type="button"
190 aria-current={ptab === tab.id ? 'page' : undefined}
191 onClick={() => onPtabChange(tab.id)}
192 className={`inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-[13px] transition-colors ${
193 ptab === tab.id
194 ? 'bg-shell-active font-semibold text-shell-ink'
195 : 'text-shell-ink-muted hover:text-shell-ink'
196 }`}
197 >
198 <Icon className="h-3.5 w-3.5" aria-hidden />
199 {t(SUB_TAB_KEYS[tab.id])}
200 {tab.id === 'files' && pull.files_changed ? ` (${pull.files_changed})` : ''}
201 {tab.id === 'commits' && pull.commits_ahead ? ` (${pull.commits_ahead})` : ''}
202 </button>
203 )
204 })}
205 </div>
206
207 {ptab === 'conversation' ? (
208 <Conversation
209 refObj={refObj}
210 pull={pull}
211 onChanged={refresh}
212 beforeComposer={
213 pull.state === 'open' && canWrite ? (
214 <MergeBox
215 refObj={refObj}
216 pull={pull}
217 gate={gate}
218 analyzing={gateState.loading && !gateState.data}
219 onChanged={refresh}
220 onError={setError}
221 />
222 ) : null
223 }
224 onOpenFile={(path) => {
225 setFocusFile(path)
226 onPtabChange('files')
227 }}
228 />
229 ) : null}
230 {ptab === 'files' ? (
231 <FilesTab
232 key={`${pull.head.sha}:${focusFile}:${focusLine}`}
233 refObj={refObj}
234 headSha={pull.head.sha}
235 focusPath={focusFile}
236 focusLine={focusLine}
237 onChanged={refresh}
238 />
239 ) : null}
240 {ptab === 'commits' ? <CommitsTab refObj={refObj} onOpenCommit={onOpenCommit} /> : null}
241 {ptab === 'checks' ? <ChecksTab refObj={refObj} headSha={pull.head.sha} /> : null}
242 </div>
243
244 <div className="w-full shrink-0 space-y-3 lg:w-[300px]">
245 {canWrite ? (
246 <SideSection title={t('pull.detail.actions')}>
247 <div className="flex flex-wrap gap-1.5">
248 {canWrite && pull.state === 'open' ? (
249 <Button
250 variant="secondary"
251 className="h-7 gap-1 text-[12px]"
252 onClick={() => void run(t('pull.detail.close'), () => closePull(refObj))}
253 >
254 <X className="h-3 w-3" aria-hidden />
255 {t('pull.detail.close')}
256 </Button>
257 ) : canWrite && pull.state === 'closed' ? (
258 <Button
259 variant="secondary"
260 className="h-7 gap-1 text-[12px]"
261 onClick={() => void run(t('pull.detail.reopen'), () => reopenPull(refObj))}
262 >
263 <RotateCcw className="h-3 w-3" aria-hidden />
264 {t('pull.detail.reopen')}
265 </Button>
266 ) : null}
267 {canWrite ? <EditButton refObj={refObj} pull={pull} onChanged={refresh} onError={setError} /> : null}
268 {canWrite ? (
269 <Button
270 variant="secondary"
271 className="h-7 gap-1 text-[12px]"
272 onClick={() =>
273 void run(pull.locked ? t('pull.detail.unlock') : t('pull.detail.lock'), () =>
274 pull.locked ? unlockPull(refObj) : lockPull(refObj),
275 )
276 }
277 >
278 {pull.locked ? (
279 <Unlock className="h-3 w-3" aria-hidden />
280 ) : (
281 <ShieldCheck className="h-3 w-3" aria-hidden />
282 )}
283 {pull.locked ? t('pull.detail.unlockDiscussion') : t('pull.detail.lockDiscussion')}
284 </Button>
285 ) : null}
286 {canWrite ? (
287 <Button
288 variant="secondary"
289 className="h-7 gap-1 text-[12px]"
290 onClick={() => void run(t('pull.detail.refreshSummary'), () => refreshPullSummary(refObj))}
291 >
292 <RotateCcw className="h-3 w-3" aria-hidden />
293 {t('pull.detail.refreshSummary')}
294 </Button>
295 ) : null}
296 <Button
297 variant="secondary"
298 className="h-7 gap-1 text-[12px]"
299 onClick={() => window.open(pullPatchUrl(refObj), '_blank', 'noopener')}
300 >
301 <Download className="h-3 w-3" aria-hidden />
302 {t('pull.detail.patch')}
303 </Button>
304 {canWrite ? (
305 <Button
306 variant="ghost"
307 className="h-7 text-[12px] text-[#b42318]"
308 onClick={() => {
309 if (!window.confirm(t('pull.detail.deleteConfirm', { number: pull.number }))) return
310 void (async () => {
311 try {
312 await deletePull(refObj)
313 onBackToList()
314 } catch (f) {
315 setError(
316 t('pull.detail.deleteFailed', {
317 reason: f instanceof Error ? f.message : t('common.state.unknown'),
318 }),
319 )
320 }
321 })()
322 }}
323 >
324 {t('common.action.delete')}
325 </Button>
326 ) : null}
327 </div>
328 </SideSection>
329 ) : null}
330
331 <ReviewersPanel refObj={refObj} pull={pull} tick={tick} onChanged={refresh} onError={setError} />
332 <AssigneesPanel refObj={refObj} pull={pull} tick={tick} onChanged={refresh} onError={setError} />
333 <LabelsPanel refObj={refObj} tick={tick} onChanged={refresh} onError={setError} />
334
335 <SideSection title={t('pull.detail.stats')}>
336 <dl className="space-y-1 text-[12.5px] text-shell-ink-muted">
337 <div className="flex justify-between">
338 <dt>{t('pull.detail.aheadBehind')}</dt>
339 <dd className="font-mono text-shell-ink">
340 {pull.commits_ahead ?? '—'} / {pull.commits_behind ?? '—'}
341 </dd>
342 </div>
343 <div className="flex justify-between">
344 <dt>{t('pull.detail.filesChanged')}</dt>
345 <dd className="font-mono text-shell-ink">{pull.files_changed}</dd>
346 </div>
347 <div className="flex justify-between">
348 <dt>{t('pull.detail.lines')}</dt>
349 <dd className="font-mono">
350 <span className="text-[#1a7f37]">+{pull.additions}</span>{' '}
351 <span className="text-[#b42318]">−{pull.deletions}</span>
352 </dd>
353 </div>
354 <div className="flex justify-between">
355 <dt>{t('pull.detail.created')}</dt>
356 <dd title={fmtTime(pull.created_at)}>{fmtRelative(pull.created_at)}</dd>
357 </div>
358 {pull.merged_at ? (
359 <div className="flex justify-between">
360 <dt>{t('pull.detail.mergedAt')}</dt>
361 <dd>{fmtRelative(pull.merged_at)}</dd>
362 </div>
363 ) : null}
364 {pull.merged_commit_sha ? (
365 <div className="flex justify-between">
366 <dt>{t('pull.detail.mergeCommit')}</dt>
367 <dd className="font-mono">{pull.merged_commit_sha.slice(0, 10)}</dd>
368 </div>
369 ) : null}
370 </dl>
371 </SideSection>
372 </div>
373 </div>
374 )
375}
376
377/* ------------------------------------------------------------ merge box */
378
379function MergeBox({
380 refObj,
381 pull,
382 gate,
383 analyzing,
384 onChanged,
385 onError,
386}: {
387 refObj: PullRef
388 pull: Pull
389 gate: PullGate | null | undefined
390 /** 合并冲突分析尚未完成:先显示“分析中”,不提前给出可合并结论。 */
391 analyzing: boolean
392 onChanged: () => void
393 onError: (message: string) => void
394}) {
395 const { t } = useT()
396 const [method, setMethod] = useState<MergeMethod>(pull.merge_method || 'merge')
397 const [title, setTitle] = useState('')
398 const [message, setMessage] = useState('')
399 const [deleteAfter, setDeleteAfter] = useState(false)
400 const [busy, setBusy] = useState(false)
401
402 const g = gate?.gate
403 const conflicts = gate?.conflicts ?? pull.conflicts ?? []
404 const blocked = !g?.allowed && g !== null && g !== undefined
405 const stale = (pull.commits_behind ?? 0) > 0
406
407 async function merge() {
408 setBusy(true)
409 try {
410 const outcome = await mergePull(refObj, {
411 method,
412 title: title.trim() || null,
413 message: message.trim() || null,
414 delete_branch_after_merge: deleteAfter,
415 expect_head_sha: pull.head.sha,
416 })
417 onChanged()
418 window.alert(t('pull.detail.mergeSucceeded', { sha: outcome.result.new_sha.slice(0, 10) }))
419 } catch (f) {
420 onError(t('pull.detail.mergeFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
421 } finally {
422 setBusy(false)
423 }
424 }
425
426 return (
427 <section className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
428 <h4 className="mb-2 text-[13px] font-semibold text-shell-ink">{t('pull.detail.merge')}</h4>
429 {analyzing ? (
430 <p className="flex items-center gap-1.5 text-[12.5px] text-shell-ink-muted">
431 <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
432 {t('pull.detail.analyzingMerge')}
433 </p>
434 ) : pull.is_draft ? (
435 <p className="text-[12.5px] text-[#8a6100]">{t('pull.detail.draftMergeHint')}</p>
436 ) : conflicts.length ? (
437 <div className="text-[12.5px] text-[#b42318]">
438 {t('pull.detail.conflicts', { count: conflicts.length })}
439 <ul className="mt-1 max-h-32 space-y-0.5 overflow-y-auto">
440 {conflicts.map((path) => (
441 <li key={path}>
442 <ConflictFileButton
443 space={refObj.space}
444 repo={refObj.repo}
445 base={pull.base.branch}
446 head={pull.head.branch}
447 path={path}
448 />
449 </li>
450 ))}
451 </ul>
452 </div>
453 ) : g && !g.allowed ? (
454 <p className="text-[12.5px] text-[#8a6100]">
455 {t('pull.detail.blockedByRules', {
456 reasons:
457 g.reasons.join('; ') ||
458 t('pull.detail.needsApprovals', { required: g.required_approvals, current: g.approvals }),
459 })}
460 {g.bypassed ? t('pull.detail.canBypass') : ''}
461 </p>
462 ) : stale ? (
463 <p className="text-[12.5px] text-[#8a6100]">
464 {t('pull.detail.staleBranch', { count: pull.commits_behind ?? 0 })}
465 </p>
466 ) : (
467 <p className="text-[12.5px] text-[#1a7f37]">
468 {t('pull.detail.readyToMerge')}
469 {g ? t('pull.detail.approvals', { current: g.approvals, required: g.required_approvals }) : ''}
470 </p>
471 )}
472
473 {g?.outdated && stale ? (
474 <Button
475 variant="secondary"
476 className="mt-2 h-7 w-full text-[12px]"
477 disabled={busy}
478 onClick={() => {
479 void (async () => {
480 try {
481 await updateBranchPull(refObj)
482 onChanged()
483 } catch (f) {
484 onError(
485 t('pull.detail.syncFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }),
486 )
487 }
488 })()
489 }}
490 >
491 {t('pull.detail.syncBranch')}
492 </Button>
493 ) : null}
494
495 <div className="mt-2 space-y-2">
496 <label className="block">
497 <span className={modalLabelClass}>{t('pull.mergeMethod')}</span>
498 <select
499 className={modalInputClass}
500 value={method}
501 onChange={(event) => setMethod(event.target.value as MergeMethod)}
502 >
503 {MERGE_METHODS.map((value) => (
504 <option key={value} value={value}>
505 {MERGE_METHOD_LABEL[value]}
506 </option>
507 ))}
508 </select>
509 </label>
510 <input
511 className={modalInputClass}
512 placeholder={t('pull.detail.mergeTitlePlaceholder')}
513 value={title}
514 onChange={(event) => setTitle(event.target.value)}
515 />
516 <textarea
517 className={modalTextareaClass}
518 rows={2}
519 placeholder={t('pull.detail.mergeMessagePlaceholder')}
520 value={message}
521 onChange={(event) => setMessage(event.target.value)}
522 />
523 <label className="flex items-center gap-2 text-[12.5px]">
524 <input type="checkbox" checked={deleteAfter} onChange={(event) => setDeleteAfter(event.target.checked)} />
525 {t('pull.detail.deleteBranchAfter')}
526 </label>
527 <Button
528 className="h-8 w-full gap-1 text-[13px]"
529 disabled={busy || analyzing || pull.is_draft || conflicts.length > 0 || blocked || stale}
530 onClick={() => void merge()}
531 >
532 <GitMerge className="h-3.5 w-3.5" aria-hidden />
533 {busy ? t('pull.detail.merging') : t('pull.detail.merge')}
534 </Button>
535 <label className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
536 <input
537 type="checkbox"
538 checked={pull.auto_merge}
539 onChange={(event) => {
540 void (async () => {
541 try {
542 await updatePull(refObj, { auto_merge: event.target.checked })
543 onChanged()
544 } catch (f) {
545 onError(
546 t('pull.detail.autoMergeFailed', {
547 reason: f instanceof Error ? f.message : t('common.state.unknown'),
548 }),
549 )
550 }
551 })()
552 }}
553 />
554 {t('pull.detail.autoMerge')}
555 </label>
556 </div>
557 </section>
558 )
559}
560
561function ConflictFileButton({
562 space,
563 repo,
564 base,
565 head,
566 path,
567}: {
568 space: string
569 repo: string
570 base: string
571 head: string
572 path: string
573}) {
574 const [detail, setDetail] = useState<string | null | 'loading'>(null)
575 return (
576 <>
577 <button
578 type="button"
579 className="block max-w-full truncate text-left font-mono text-[12px] underline underline-offset-2 hover:brightness-125"
580 title={tr('pull.detail.viewConflict')}
581 onClick={() => {
582 setDetail('loading')
583 void fetchConflictDetail(space, repo, base, head, path)
584 .then((d) => setDetail(d.content ?? tr('pull.detail.noConflictText')))
585 .catch(() => setDetail(null))
586 }}
587 >
588 {path}
589 </button>
590 {detail === null ? null : (
591 <Modal
592 title={tr('pull.detail.conflictContent', { path })}
593 open
594 width="max-w-[760px]"
595 onClose={() => setDetail(null)}
596 footer={
597 <Button variant="ghost" onClick={() => setDetail(null)}>
598 {tr('common.action.close')}
599 </Button>
600 }
601 >
602 {detail === 'loading' ? (
603 <p className="text-[12.5px] text-shell-ink-muted">{tr('pull.detail.loadingConflict')}</p>
604 ) : (
605 <pre className="max-h-[60vh] overflow-auto rounded-lg border border-shell-hairline bg-shell-field p-3 font-mono text-[11.5px] leading-5">
606 {detail}
607 </pre>
608 )}
609 </Modal>
610 )}
611 </>
612 )
613}
614
615/* ---------------------------------------------------------- edit dialog */
616
617function EditButton({
618 refObj,
619 pull,
620 onChanged,
621 onError,
622}: {
623 refObj: PullRef
624 pull: Pull
625 onChanged: () => void
626 onError: (m: string) => void
627}) {
628 const { t } = useT()
629 const [open, setOpen] = useState(false)
630 const [title, setTitle] = useState(pull.title)
631 const [body, setBody] = useState(pull.body)
632 const [draft, setDraft] = useState(pull.is_draft)
633 const [busy, setBusy] = useState(false)
634
635 async function save() {
636 setBusy(true)
637 try {
638 await updatePull(refObj, { title: title.trim() || undefined, body, draft })
639 setOpen(false)
640 onChanged()
641 } catch (f) {
642 onError(t('pull.detail.updateFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
643 } finally {
644 setBusy(false)
645 }
646 }
647
648 return (
649 <>
650 <Button
651 variant="secondary"
652 className="h-7 gap-1 text-[12px]"
653 onClick={() => {
654 setTitle(pull.title)
655 setBody(pull.body)
656 setDraft(pull.is_draft)
657 setOpen(true)
658 }}
659 >
660 <Pencil className="h-3 w-3" aria-hidden />
661 {t('issue.board.edit')}
662 </Button>
663 {open ? (
664 <Modal
665 title={t('pull.detail.editNumber', { number: pull.number })}
666 open
667 onClose={() => setOpen(false)}
668 footer={
669 <>
670 <Button variant="ghost" onClick={() => setOpen(false)}>
671 {t('common.action.cancel')}
672 </Button>
673 <Button disabled={busy} onClick={() => void save()}>
674 {t('common.action.save')}
675 </Button>
676 </>
677 }
678 >
679 <label className="block">
680 <span className={modalLabelClass}>{t('issue.board.form.title')}</span>
681 <input className={modalInputClass} value={title} onChange={(event) => setTitle(event.target.value)} />
682 </label>
683 <label className="block">
684 <span className={modalLabelClass}>{t('pull.description')}</span>
685 <textarea
686 className={modalTextareaClass}
687 rows={7}
688 value={body}
689 onChange={(event) => setBody(event.target.value)}
690 />
691 </label>
692 <label className="flex items-center gap-2 text-[13px]">
693 <input type="checkbox" checked={draft} onChange={(event) => setDraft(event.target.checked)} />
694 {t('pull.detail.draftCheckbox')}
695 </label>
696 </Modal>
697 ) : null}
698 </>
699 )
700}
701
702/* ------------------------------------------------------------ side panels */
703
704function ReviewersPanel({
705 refObj,
706 pull,
707 tick,
708 onChanged,
709 onError,
710}: {
711 refObj: PullRef
712 pull: Pull
713 tick: number
714 onChanged: () => void
715 onError: (m: string) => void
716}) {
717 const { t } = useT()
718 const [required, setRequired] = useState(true)
719 const reviewers = useAsync(() => listRequestedReviewers(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
720 const rows = reviewers.data ?? []
721 const members = useAsync(() => listSpaceMembers(refObj.space), [refObj.space])
722 const memberOptions = (members.data?.items ?? [])
723 .map((row) => row.user)
724 .filter((user): user is NonNullable<typeof user> => Boolean(user))
725 .filter((user) => !rows.some((reviewer) => reviewer.user?.username === user.username))
726 .map((user) => ({
727 value: user.username,
728 label: user.display_name || user.username,
729 hint: `@${user.username}`,
730 icon: <Avatar user={user} size={16} />,
731 }))
732
733 async function add(name: string) {
734 try {
735 await requestReviewers(refObj, [name], required)
736 onChanged()
737 } catch (f) {
738 onError(
739 t('pull.detail.requestReviewFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }),
740 )
741 throw f
742 }
743 }
744
745 return (
746 <SideSection
747 title={`${t('pull.detail.reviewers')}${rows.length ? ` (${rows.length})` : ''}`}
748 action={
749 <label className="flex items-center gap-1 text-[11.5px] text-shell-ink-muted">
750 <input type="checkbox" checked={required} onChange={(event) => setRequired(event.target.checked)} />
751 {t('pull.detail.required')}
752 </label>
753 }
754 >
755 {rows.length ? (
756 <ul className="space-y-1.5">
757 {rows.map((row) => (
758 <li key={row.id} className="flex items-center gap-2 text-[13px]">
759 <Avatar user={row.user} size={20} />
760 <span className="min-w-0 flex-1 truncate">{userName(row.user)}</span>
761 <span className="text-[11.5px] text-shell-ink-muted">{reviewerStateLabel(row.state)}</span>
762 {row.state === 'requested' && pull.state === 'open' ? (
763 row.user ? (
764 <button
765 type="button"
766 aria-label={t('pull.detail.remove', { name: userName(row.user) })}
767 className="text-shell-ink-muted hover:text-[#b42318]"
768 onClick={() =>
769 void removeRequestedReviewer(refObj, row.user?.user_id ?? 0)
770 .then(onChanged)
771 .catch(() => undefined)
772 }
773 >
774 ×
775 </button>
776 ) : null
777 ) : null}
778 </li>
779 ))}
780 </ul>
781 ) : (
782 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noReviewers')}</p>
783 )}
784 {pull.state === 'open' ? (
785 <SearchSelect
786 placeholder={t('pull.detail.reviewRequestPlaceholder')}
787 options={memberOptions}
788 emptyText={t('pull.detail.pickNoMatch')}
789 onSelect={(value) => void add(value).catch(() => undefined)}
790 />
791 ) : null}
792 </SideSection>
793 )
794}
795
796function reviewerStateLabel(state: ReviewerState | string): string {
797 return REVIEWER_STATE_LABEL[state as ReviewerState] ?? state
798}
799
800function AssigneesPanel({
801 refObj,
802 pull,
803 tick,
804 onChanged,
805 onError,
806}: {
807 refObj: PullRef
808 pull: Pull
809 tick: number
810 onChanged: () => void
811 onError: (m: string) => void
812}) {
813 const { t } = useT()
814 const assignees = useAsync(() => listAssignees(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
815 const rows = assignees.data ?? []
816 const members = useAsync(() => listSpaceMembers(refObj.space), [refObj.space])
817 const memberOptions = (members.data?.items ?? [])
818 .map((row) => row.user)
819 .filter((user): user is NonNullable<typeof user> => Boolean(user))
820 .filter((user) => !rows.some((assignee) => assignee.user?.username === user.username))
821 .map((user) => ({
822 value: user.username,
823 label: user.display_name || user.username,
824 hint: `@${user.username}`,
825 icon: <Avatar user={user} size={16} />,
826 }))
827 return (
828 <SideSection title={`${t('pull.detail.assignees')}${rows.length ? ` (${rows.length})` : ''}`}>
829 {rows.length ? (
830 <ul className="space-y-1.5">
831 {rows.map((row) => (
832 <li key={row.id} className="flex items-center gap-2 text-[13px]">
833 <Avatar user={row.user} size={20} />
834 <span className="min-w-0 flex-1 truncate">{userName(row.user)}</span>
835 {pull.state === 'open' ? (
836 <button
837 type="button"
838 aria-label={t('pull.detail.remove', { name: userName(row.user) })}
839 className="text-shell-ink-muted hover:text-[#b42318]"
840 onClick={() =>
841 void removeAssignee(refObj, row.user?.username ?? String(row.user?.user_id ?? ''))
842 .then(onChanged)
843 .catch(() => undefined)
844 }
845 >
846 ×
847 </button>
848 ) : null}
849 </li>
850 ))}
851 </ul>
852 ) : (
853 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noAssignees')}</p>
854 )}
855 {pull.state === 'open' ? (
856 <SearchSelect
857 placeholder={t('pull.detail.assigneePlaceholder')}
858 options={memberOptions}
859 emptyText={t('pull.detail.pickNoMatch')}
860 onSelect={(value) =>
861 void (async () => {
862 try {
863 await addAssignee(refObj, value)
864 onChanged()
865 } catch (f) {
866 onError(
867 t('pull.detail.addAssigneeFailed', {
868 reason: f instanceof Error ? f.message : t('common.state.unknown'),
869 }),
870 )
871 }
872 })()
873 }
874 />
875 ) : null}
876 </SideSection>
877 )
878}
879
880function LabelsPanel({
881 refObj,
882 tick,
883 onChanged,
884 onError,
885}: {
886 refObj: PullRef
887 tick: number
888 onChanged: () => void
889 onError: (m: string) => void
890}) {
891 const { t } = useT()
892 const labels = useAsync(() => listPullLabels(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
893 const rows = labels.data ?? []
894 const candidates = useAsync(() => listPullLabelOptions(refObj.space, refObj.repo), [refObj.space, refObj.repo, tick])
895 const labelOptions = (candidates.data ?? [])
896 .filter((label) => !rows.some((bound) => bound.id === label.id))
897 .map((label) => ({ value: label.id, label: label.name, color: label.color }))
898 return (
899 <SideSection title={t('issue.board.labelsField')}>
900 {rows.length ? (
901 <div className="flex flex-wrap gap-1.5">
902 {rows.map((label) => (
903 <LabelPillInline
904 key={label.id}
905 name={label.name}
906 color={label.color}
907 onRemove={() =>
908 void detachLabel(refObj, label.id)
909 .then(onChanged)
910 .catch(() => undefined)
911 }
912 />
913 ))}
914 </div>
915 ) : (
916 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noLabels')}</p>
917 )}
918 <SearchSelect
919 placeholder={t('pull.detail.labelPlaceholder')}
920 options={labelOptions}
921 emptyText={t('pull.detail.pickNoMatch')}
922 onSelect={(value) =>
923 void (async () => {
924 try {
925 await attachLabel(refObj, value)
926 onChanged()
927 } catch (f) {
928 onError(
929 t('pull.detail.attachLabelFailed', {
930 reason: f instanceof Error ? f.message : t('common.state.unknown'),
931 }),
932 )
933 }
934 })()
935 }
936 />
937 </SideSection>
938 )
939}
940
941function LabelPillInline({ name, color, onRemove }: { name: string; color?: string | null; onRemove: () => void }) {
942 const { t } = useT()
943 return (
944 <span
945 className="inline-flex items-center gap-1 rounded-full border border-shell-hairline px-2 py-0.5 text-[11.5px]"
946 style={color ? { backgroundColor: `${color}22`, borderColor: color } : undefined}
947 >
948 {name}
949 <button
950 type="button"
951 aria-label={t('issue.board.removeLabel', { name })}
952 onClick={onRemove}
953 className="text-shell-ink-muted hover:text-shell-ink"
954 >
955 ×
956 </button>
957 </span>
958 )
959}
Added view/app/src/domains/pulls/ui/pull-form-modal.tsx View view/app/src/domains/pulls/ui/pull-form-modal.tsx
@@ -0,0 +1,194 @@
1import { Modal, modalInputClass, modalLabelClass, modalTextareaClass } from '@app/components'
2import type { MergeStatus, Pull } from '@app/domains/pulls/model'
3import { checkMergeStatus, createPull } from '@app/domains/pulls/model'
4import { listBranches } from '@app/domains/repo/model'
5import { useT } from '@app/i18n'
6import { Button } from '@app/ui'
7import { AlertCircle, CheckCircle2 } from 'lucide-react'
8import { useEffect, useState } from 'react'
9
10export interface PullFormModalProps {
11 space: string
12 repo: string
13 defaultBase: string
14 onClose: () => void
15 onCreated: (pull: Pull) => void
16}
17
18/** 新建合并请求:选分支、填标题描述,并实时探测 base…head 的可合并性。 */
19export function PullFormModal({ space, repo, defaultBase, onClose, onCreated }: PullFormModalProps) {
20 const { t } = useT()
21 const [branches, setBranches] = useState<string[]>([])
22 const [head, setHead] = useState('')
23 const [base, setBase] = useState(defaultBase)
24 const [title, setTitle] = useState('')
25 const [body, setBody] = useState('')
26 const [draft, setDraft] = useState(false)
27 const [busy, setBusy] = useState(false)
28 const [error, setError] = useState<string | null>(null)
29 const [probe, setProbe] = useState<{ key: string; value: MergeStatus | 'failed' } | null>(null)
30
31 useEffect(() => {
32 let active = true
33 void listBranches(space, repo, 200)
34 .then((page) => {
35 if (!active) return
36 const names = page.items.map((item) => item.name)
37 setBranches(names)
38 setHead((prev) => prev || names.find((name) => name !== defaultBase) || names[0] || '')
39 })
40 .catch(() => undefined)
41 return () => {
42 active = false
43 }
44 }, [space, repo, defaultBase])
45
46 const pairKey = head && base && head !== base ? `${space}/${repo}:${base}...${head}` : ''
47 // 'pending' is derived (pair selected, no result yet) so the effect only
48 // writes state when the probe settles.
49 const status: MergeStatus | 'pending' | null = !pairKey
50 ? null
51 : probe?.key === pairKey
52 ? probe.value === 'failed'
53 ? null
54 : probe.value
55 : 'pending'
56
57 useEffect(() => {
58 if (!pairKey) return
59 let active = true
60 const timer = setTimeout(() => {
61 void checkMergeStatus(space, repo, base, head)
62 .then((next) => {
63 if (active) setProbe({ key: pairKey, value: next })
64 })
65 .catch(() => {
66 if (active) setProbe({ key: pairKey, value: 'failed' })
67 })
68 }, 400)
69 return () => {
70 active = false
71 clearTimeout(timer)
72 }
73 }, [space, repo, base, head, pairKey])
74
75 async function submit() {
76 if (!title.trim() || !head || !base) {
77 setError(t('pull.form.missingFields'))
78 return
79 }
80 if (head === base) {
81 setError(t('pull.form.sameBranch'))
82 return
83 }
84 setBusy(true)
85 setError(null)
86 try {
87 const pull = await createPull(space, repo, {
88 title: title.trim(),
89 head_branch: head,
90 base_branch: base,
91 body: body || null,
92 draft,
93 })
94 onCreated(pull)
95 } catch (failure) {
96 setError(failure instanceof Error ? failure.message : t('pull.form.createFailed'))
97 setBusy(false)
98 }
99 }
100
101 return (
102 <Modal
103 title={t('pull.form.title')}
104 description={t('pull.form.description', { repo })}
105 open
106 onClose={onClose}
107 footer={
108 <>
109 <Button variant="ghost" onClick={onClose}>
110 {t('common.action.cancel')}
111 </Button>
112 <Button disabled={busy} onClick={() => void submit()}>
113 {busy ? t('pull.form.creating') : draft ? t('pull.form.createDraft') : t('pull.form.create')}
114 </Button>
115 </>
116 }
117 >
118 <div className="grid gap-3 sm:grid-cols-2">
119 <label>
120 <span className={modalLabelClass}>{t('pull.form.head')}</span>
121 <select className={modalInputClass} value={head} onChange={(event) => setHead(event.target.value)}>
122 {branches.map((name) => (
123 <option key={name} value={name}>
124 {name}
125 </option>
126 ))}
127 </select>
128 </label>
129 <label>
130 <span className={modalLabelClass}>{t('pull.form.base')}</span>
131 <select className={modalInputClass} value={base} onChange={(event) => setBase(event.target.value)}>
132 {branches.map((name) => (
133 <option key={name} value={name}>
134 {name}
135 </option>
136 ))}
137 </select>
138 </label>
139 </div>
140
141 {head && base && head !== base ? (
142 status === 'pending' ? (
143 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.form.comparing', { base, head })}</p>
144 ) : status?.mergeable ? (
145 <p className="flex items-center gap-1.5 text-[12.5px] text-[#1a7f37]">
146 <CheckCircle2 className="h-3.5 w-3.5" aria-hidden />
147 {t('pull.form.fastForward')}
148 {status.fast_forwardable ? '' : t('pull.form.willMergeCommit')}
149 </p>
150 ) : status ? (
151 <p className="flex items-start gap-1.5 text-[12.5px] text-[#b42318]">
152 <AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
153 <span>
154 {t('pull.form.conflicts', { count: status.conflicts.length })}
155 {status.conflicts.length ? (
156 <span className="block break-all font-mono text-[11px]">
157 {status.conflicts.slice(0, 5).join(', ')}
158 {status.conflicts.length > 5 ? ' …' : ''}
159 </span>
160 ) : null}
161 {t('pull.form.conflictsHint')}
162 </span>
163 </p>
164 ) : null
165 ) : null}
166
167 <label>
168 <span className={modalLabelClass}>{t('pull.title')}</span>
169 <input
170 className={modalInputClass}
171 value={title}
172 maxLength={200}
173 placeholder={head ? t('pull.form.titlePlaceholder', { head, base }) : t('pull.form.titlePlaceholderFallback')}
174 onChange={(event) => setTitle(event.target.value)}
175 />
176 </label>
177 <label>
178 <span className={modalLabelClass}>{t('pull.form.descriptionField')}</span>
179 <textarea
180 className={modalTextareaClass}
181 rows={6}
182 value={body}
183 placeholder={t('pull.form.bodyPlaceholder')}
184 onChange={(event) => setBody(event.target.value)}
185 />
186 </label>
187 <label className="flex items-center gap-2 text-[13px]">
188 <input type="checkbox" checked={draft} onChange={(event) => setDraft(event.target.checked)} />
189 {t('pull.form.draftCheckbox')}
190 </label>
191 {error ? <p className="text-[12.5px] text-[#b42318]">{error}</p> : null}
192 </Modal>
193 )
194}
Added view/app/src/domains/pulls/ui/pulls-tab.tsx View view/app/src/domains/pulls/ui/pulls-tab.tsx
@@ -0,0 +1,254 @@
1import { ErrorPanel, PanelNote } from '@app/components'
2import type { MyPullScope } from '@app/domains/pulls/model'
3import { listMyPulls, listPulls, MY_PULL_SCOPES } from '@app/domains/pulls/model'
4import { useT } from '@app/i18n'
5import { fmtRelative, repoKey, useAsync } from '@app/lib'
6import { Button } from '@app/ui'
7import { Inbox, Plus, Search } from 'lucide-react'
8import { useMemo, useState } from 'react'
9import { PullFilter, type PullFilterValue } from '../model/filters'
10import { userName } from '../model/meta'
11import { Avatar, MergeableChip, PullStateBadge } from './parts'
12import { PullFormModal } from './pull-form-modal'
13
14const PAGE_SIZE = 20
15
16export interface PullsTabProps {
17 space: string
18 repo: string
19 /** `RepoDetail.id` — the inbox can only deep-link PRs of this repo. */
20 repoId: string
21 defaultBranch: string
22 /** Repo write access (space owner / repo collaborator server-side). */
23 canWrite: boolean
24 /** 详情面板当前打开的 pull_ref;与其匹配的行再次点击即关闭详情。 */
25 activePull?: string | null
26 onOpenPull: (pullRef: string) => void
27 onClosePull: () => void
28}
29
30const SCOPE_KEYS: Record<
31 MyPullScope,
32 'pull.list.scope.created' | 'pull.list.scope.reviewRequested' | 'pull.list.scope.reviewed'
33> = {
34 created: 'pull.list.scope.created',
35 'review-requested': 'pull.list.scope.reviewRequested',
36 reviewed: 'pull.list.scope.reviewed',
37}
38
39/** 合并请求 页签:本仓库列表 + “我的 PR” 收件箱(限定本仓库,可深链)。 */
40export function PullsTab({
41 space,
42 repo,
43 repoId,
44 defaultBranch,
45 canWrite,
46 activePull,
47 onOpenPull,
48 onClosePull,
49}: PullsTabProps) {
50 const { t, tc } = useT()
51 const [source, setSource] = useState<'repo' | 'mine'>('repo')
52 const [scope, setScope] = useState<MyPullScope>('review-requested')
53 const [filter, setFilter] = useState<PullFilterValue>({ state: 'open', search: '' })
54 const [offset, setOffset] = useState(0)
55 const [creating, setCreating] = useState(false)
56 const [tick, setTick] = useState(0)
57
58 // Filter/source changes reset paging inline (no effect-state ping-pong).
59 const withReset = (fn: () => void) => () => {
60 fn()
61 setOffset(0)
62 }
63
64 const state = useAsync(
65 async () => {
66 if (source === 'repo') {
67 return listPulls(space, repo, {
68 state: filter.state,
69 ...(filter.search ? { search: filter.search } : {}),
70 limit: PAGE_SIZE,
71 offset,
72 })
73 }
74 const page = await listMyPulls(scope, { state: filter.state, limit: 50, offset: 0 })
75 const rows = page.items.filter(
76 (pull) =>
77 pull.repo_id === repoId &&
78 (filter.state !== 'draft' || pull.is_draft) &&
79 (!filter.search || pull.title.toLowerCase().includes(filter.search.toLowerCase())),
80 )
81 return {
82 items: rows.slice(offset, offset + PAGE_SIZE),
83 total: rows.length,
84 limit: PAGE_SIZE,
85 offset,
86 }
87 },
88 [space, repo, repoId, source, scope, filter.state, filter.search, offset, tick],
89 { cacheKey: repoKey(space, repo, `pulls:${source}:${scope}:${filter.state}:${filter.search}:${offset}`) },
90 )
91
92 const page = state.data
93 const rows = useMemo(() => page?.items ?? [], [page])
94 const failure = state.error as { code?: number; message?: string } | undefined
95
96 return (
97 <div className="space-y-3 pb-6">
98 <div className="flex flex-wrap items-center gap-2">
99 <div className="flex items-center gap-0.5 rounded-md bg-shell-field p-0.5">
100 {(['repo', 'mine'] as const).map((value) => (
101 <button
102 key={value}
103 type="button"
104 aria-pressed={source === value}
105 onClick={withReset(() => setSource(value))}
106 className={`flex items-center gap-1 rounded px-2.5 py-1 text-[12.5px] transition-colors ${
107 source === value
108 ? 'bg-shell-active font-semibold text-shell-ink'
109 : 'text-shell-ink-muted hover:text-shell-ink'
110 }`}
111 >
112 {value === 'repo' ? (
113 t('pull.list.repo')
114 ) : (
115 <>
116 <Inbox className="h-3.5 w-3.5" aria-hidden />
117 {t('pull.list.mine')}
118 </>
119 )}
120 </button>
121 ))}
122 </div>
123 {source === 'mine' ? (
124 <div className="flex items-center gap-0.5 rounded-md bg-shell-field p-0.5">
125 {MY_PULL_SCOPES.map((value) => (
126 <button
127 key={value}
128 type="button"
129 aria-pressed={scope === value}
130 onClick={withReset(() => setScope(value))}
131 className={`rounded px-2.5 py-1 text-[12.5px] transition-colors ${
132 scope === value
133 ? 'bg-shell-active font-semibold text-shell-ink'
134 : 'text-shell-ink-muted hover:text-shell-ink'
135 }`}
136 >
137 {t(SCOPE_KEYS[value])}
138 </button>
139 ))}
140 </div>
141 ) : (
142 <label className="relative hidden items-center sm:flex">
143 <Search className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
144 <input
145 value={filter.search}
146 onChange={(event) => withReset(() => setFilter((prev) => ({ ...prev, search: event.target.value })))()}
147 placeholder={t('pull.list.searchPlaceholder')}
148 className="h-8 w-52 rounded-md border border-shell-hairline bg-shell-field pl-8 pr-2 text-[12.5px] outline-none focus:border-shell-tile"
149 />
150 </label>
151 )}
152 {canWrite ? (
153 <Button className="ml-auto h-8 gap-1 text-[12.5px]" onClick={() => setCreating(true)}>
154 <Plus className="h-3.5 w-3.5" aria-hidden /> {t('pull.list.new')}
155 </Button>
156 ) : null}
157 </div>
158
159 <PullFilter value={filter} onChange={(next) => withReset(() => setFilter(next))()} />
160
161 {state.loading && !page ? <PanelNote text={t('pull.list.loading')} /> : null}
162 {!state.loading && failure ? (
163 <ErrorPanel
164 status={failure.code ?? 0}
165 what={t('pull.list.errorList')}
166 note={failure.message}
167 onRetry={() => setTick((tick) => tick + 1)}
168 />
169 ) : null}
170 {!state.loading && !failure && rows.length === 0 ? (
171 <PanelNote text={source === 'mine' ? t('pull.list.emptyInbox') : t('pull.list.empty')} />
172 ) : null}
173
174 {rows.length > 0 ? (
175 <ul className="divide-y divide-shell-hairline rounded-xl border border-shell-hairline bg-shell-surface">
176 {rows.map((pull) => {
177 const ref = String(pull.number)
178 const active = activePull != null && (activePull === ref || activePull === pull.id)
179 return (
180 <li key={pull.id} className={`px-4 py-3 ${active ? 'bg-shell-active/50' : ''}`}>
181 <button
182 type="button"
183 aria-current={active ? 'true' : undefined}
184 onClick={() => (active ? onClosePull() : onOpenPull(ref))}
185 className="group flex w-full items-start gap-3 text-left"
186 >
187 <span className="mt-0.5">
188 <PullStateBadge pull={pull} />
189 </span>
190 <span className="min-w-0 flex-1">
191 <span className="block truncate text-[14px] font-semibold leading-6 group-hover:underline">
192 {pull.title}
193 </span>
194 <span className="mt-0.5 block text-[12px] text-shell-ink-muted">
195 #{pull.number} · {pull.head.branch} → {pull.base.branch}
196 <span className="mx-1">·</span>
197 {userName(pull.author)} · {fmtRelative(pull.created_at)}
198 {pull.mergeable_state ? (
199 <>
200 <span className="mx-1">·</span>
201 <MergeableChip state={pull.mergeable_state} />
202 </>
203 ) : null}
204 </span>
205 </span>
206 <Avatar user={pull.author} size={22} />
207 </button>
208 </li>
209 )
210 })}
211 </ul>
212 ) : null}
213
214 {page && page.total > PAGE_SIZE ? (
215 <div className="flex items-center gap-3 text-[12.5px] text-shell-ink-muted">
216 <Button
217 variant="secondary"
218 className="h-7 text-[12px]"
219 disabled={offset <= 0}
220 onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
221 >
222 {t('issue.board.prev')}
223 </Button>
224 <span className="rounded bg-shell-active px-2 py-0.5">
225 {Math.floor(offset / PAGE_SIZE) + 1} / {Math.max(1, Math.ceil(page.total / PAGE_SIZE))}
226 </span>
227 <Button
228 variant="secondary"
229 className="h-7 text-[12px]"
230 disabled={offset + PAGE_SIZE >= page.total}
231 onClick={() => setOffset(offset + PAGE_SIZE)}
232 >
233 {t('issue.board.next')}
234 </Button>
235 <span>{tc('pull.list.total', page.total)}</span>
236 </div>
237 ) : null}
238
239 {creating ? (
240 <PullFormModal
241 space={space}
242 repo={repo}
243 defaultBase={defaultBranch}
244 onClose={() => setCreating(false)}
245 onCreated={(pull) => {
246 setCreating(false)
247 setTick((t) => t + 1)
248 onOpenPull(String(pull.number))
249 }}
250 />
251 ) : null}
252 </div>
253 )
254}
Added view/app/src/domains/pulls/index.ts View view/app/src/domains/pulls/index.ts
@@ -0,0 +1,5 @@
1// Public API of features/pull-request — the 合并请求 tab and detail surface.
2// Consumers import from '@app/domains/pulls', never from deeper files.
3
4export { PullDetail, type PullDetailProps, type PullSubTab } from './ui/pull-detail'
5export { PullsTab, type PullsTabProps } from './ui/pulls-tab'