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
Added view/app/src/domains/pulls/model/api.ts view/app/src/domains/pulls/model/api.ts +329 −0 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/app/src/domains/pulls/model/filters.tsx +64 −0 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/app/src/domains/pulls/model/index.ts +8 −0 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/app/src/domains/pulls/model/meta.ts +145 −0 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/app/src/domains/pulls/model/pull.ts +165 −0 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/app/src/domains/pulls/ui/checks-tab.tsx +70 −0 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/app/src/domains/pulls/ui/commits-tab.tsx +68 −0 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')} <{commit.author_email}> ·{' '}
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/app/src/domains/pulls/ui/conversation.tsx +529 −0 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/app/src/domains/pulls/ui/files-tab.tsx +316 −0 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/app/src/domains/pulls/ui/parts.tsx +331 −0 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/app/src/domains/pulls/ui/pull-detail.tsx +959 −0 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/app/src/domains/pulls/ui/pull-form-modal.tsx +194 −0 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/app/src/domains/pulls/ui/pulls-tab.tsx +254 −0 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/app/src/domains/pulls/index.ts +5 −0 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'