@kubohiroya/turbowarp-plus-editor-git 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.ja.md +10 -2
  2. package/README.md +10 -2
  3. package/dist/branch-button.d.ts +15 -0
  4. package/dist/change-marks.d.ts +8 -0
  5. package/dist/connection-settings.d.ts +3 -1
  6. package/dist/github-pull-requests.d.ts +2 -2
  7. package/dist/github-review-api.d.ts +112 -0
  8. package/dist/index.d.ts +19 -0
  9. package/dist/index.js +377 -268
  10. package/dist/messages.d.ts +101 -0
  11. package/dist/{project-asset-paths-BzwVf9HE.js → project-asset-paths-DNaoWs3E.js} +1 -1
  12. package/dist/{project-comparison-BXOQcZ73.js → project-comparison-B_3QZ6M-.js} +1414 -33
  13. package/dist/project-source-diff.d.ts +5 -0
  14. package/dist/project-source-diff.js +4 -4
  15. package/dist/project-source-review.d.ts +6 -1
  16. package/dist/pull-request-panel.d.ts +2 -0
  17. package/dist/remote-operations.d.ts +1 -1
  18. package/dist/{repository-ByKRBftJ.js → repository-DXRZEtYk.js} +2 -3
  19. package/dist/repository.js +1 -1
  20. package/dist/review-composer.d.ts +23 -0
  21. package/dist/review-submit.d.ts +144 -0
  22. package/dist/{smart-http-BmHEkE7K.js → smart-http-eARzlXHO.js} +101 -2
  23. package/dist/smart-http.d.ts +2 -0
  24. package/dist/smart-http.js +1 -1
  25. package/dist/style.css +23 -0
  26. package/package.json +1 -1
  27. package/src/branch-button.tsx +79 -6
  28. package/src/change-marks.ts +22 -0
  29. package/src/connection-settings.tsx +9 -2
  30. package/src/github-review-api.ts +298 -0
  31. package/src/index.tsx +153 -15
  32. package/src/messages.ts +76 -2
  33. package/src/project-source-diff.css +10 -0
  34. package/src/project-source-diff.tsx +6 -0
  35. package/src/project-source-review.tsx +33 -5
  36. package/src/pull-request-panel.tsx +27 -6
  37. package/src/remote-operations.ts +1 -1
  38. package/src/review-composer.tsx +166 -0
  39. package/src/review-submit.ts +519 -0
  40. package/src/smart-http.ts +2 -2
  41. package/src/style.css +13 -0
  42. package/dist/objectSpread2-Dkd68nJH.js +0 -101
@@ -120,3 +120,13 @@
120
120
  .twgit-review-target-status { font-weight: 600; }
121
121
  .twgit-review-asset { display: flex; align-items: center; gap: 8px; }
122
122
  .twgit-review-asset img { width: 32px; height: 32px; object-fit: contain; }
123
+ .twgit-review-field { display: grid; gap: 4px; }
124
+ .twgit-review-field textarea { font: inherit; resize: vertical; min-height: 3em; }
125
+ .twgit-review-comment { padding: 0 12px 10px; }
126
+ .twgit-review-composer { display: grid; gap: 10px; margin-top: 14px; padding: 12px 14px; border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas)); border-radius: 8px; }
127
+ .twgit-review-composer h3 { margin: 0; }
128
+ .twgit-review-events { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0; padding: 6px 10px; border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas)); border-radius: 6px; }
129
+ .twgit-review-summary, .twgit-review-note, .twgit-review-state { margin: 0; }
130
+ .twgit-review-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
131
+ .twgit-review-result { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin: 0; }
132
+ .twgit-review-result p { margin: 0; }
@@ -8,6 +8,12 @@ import type {LocationStatus} from './block-diff-preview.js';
8
8
  import './project-source-diff.css';
9
9
 
10
10
  export {createGitHubPullRequests, gitHubRepositoryFromOrigin} from './github-pull-requests.js';
11
+ export {createGitHubReviewApi, GitHubReviewError, isReviewThreadPath} from './github-review-api.js';
12
+ export type {GitHubReviewApi, GitHubReviewApiOptions, GitHubReviewErrorCode, ReviewContext, ReviewEvent} from './github-review-api.js';
13
+ export {checkReview, discardReview, emptyReviewDraft, forgetReviewSubmission, planReview, probeReviewRoutes, readReviewDraft,
14
+ reviewBody, reviewProblem, reviewRoute, sendReview, subscribeReviewDraft, writeReviewDraft} from './review-submit.js';
15
+ export {PullRequestReviewComposer} from './review-composer.js';
16
+ export type {ReviewComment, ReviewDraft, ReviewPlan, ReviewProblem, ReviewResult, ReviewSubmission, ReviewTarget} from './review-submit.js';
11
17
  export {ProjectSourceComparison, ProjectSourceReview} from './project-source-review.js';
12
18
  export {ProjectComparisonDialog, openProjectComparison} from './project-comparison.js';
13
19
  export {openProjectSourceTabs} from './project-source-tabs.js';
@@ -11,6 +11,8 @@ import {currentCostume, useAssetImage, useCostumeImage} from './project-assets.j
11
11
  import {assetFileName, projectPropertyLabel} from './project-change-model.js';
12
12
  import {hatName, legend, summaryPhrase} from './review-words.js';
13
13
  import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
14
+ import {changeReviewDraft, useReviewDraft} from './review-composer.js';
15
+ import {reviewLabelsFor} from './messages.js';
14
16
 
15
17
  type Comparison = ReturnType<typeof compareProjectForReview>;
16
18
 
@@ -35,6 +37,11 @@ export interface ProjectSourceReviewProps {
35
37
  * the content of each script, so a mark stays until that script changes. Without it, marks last while the review is open.
36
38
  */
37
39
  rememberAs?: string;
40
+ /**
41
+ * Offers a comment field under each code change, kept in the review draft of this name (such as `owner/repo#42`) and
42
+ * sent with the review.
43
+ */
44
+ commentAs?: string;
38
45
  }
39
46
 
40
47
  // Like the Editor's Code tab, a connected group of blocks is called code, and each item is named by its hat block.
@@ -131,8 +138,12 @@ function scriptReviewTitle(script: ScriptReview, labels: typeof ja) {
131
138
  return beforeTitle && afterTitle && beforeTitle !== afterTitle ? beforeTitle + ' → ' + afterTitle : afterTitle ?? beforeTitle;
132
139
  }
133
140
 
134
- function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset, location, expanded = false}: {
141
+ type ScriptCommentField = {value: string; set(value: string, heading: string): void};
142
+
143
+ function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset, location, expanded = false, comment, target}: {
135
144
  script: ScriptReview; labels: typeof ja; reviewed?: boolean; setReviewed?(value: boolean): void;
145
+ /** The person's comment on this code, sent with the review. */
146
+ comment?: ScriptCommentField; target?: TargetReview;
136
147
  renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader; location?: BlockLocation;
137
148
  /** Starts open, as in a tab that holds this one script. */
138
149
  expanded?: boolean;
@@ -151,6 +162,7 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
151
162
  {status === 'reorganized' && <small>{script.before.length} → {script.after.length}</small>}
152
163
  {summary && <small className="twgit-review-summary">{summary}</small>}
153
164
  {reviewed && <small>{labels.reviewed}</small>}
165
+ {comment?.value && <small data-git-review-commented="">{reviewLabelsFor(locale).commented}</small>}
154
166
  </summary>
155
167
  {open && <>
156
168
  <div className="twgit-block-diff-sides">
@@ -167,6 +179,9 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
167
179
  {toggle}
168
180
  {setReviewed && <label className="twgit-review-checked"><input type="checkbox" checked={reviewed}
169
181
  onChange={event => setReviewed(event.currentTarget.checked)}/> {labels.reviewed}</label>}
182
+ {comment && <label className="twgit-review-field twgit-review-comment">{reviewLabelsFor(locale).scriptComment}
183
+ <textarea rows={3} value={comment.value} onChange={event => comment.set(event.currentTarget.value,
184
+ `${target?.isStage ? labels.stage : target?.name ?? ''} · ${title} · ${script.id}`)}/></label>}
170
185
  </>}
171
186
  </details>;
172
187
  }
@@ -208,7 +223,8 @@ function TargetThumbnails({target, labels, loadAsset}: {target: TargetReview; la
208
223
  function ComparisonTargets({comparison, labels, renderBlock, loadAsset, locateBlock, review}: {
209
224
  comparison: Comparison; labels: typeof ja; renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
210
225
  locateBlock?: ProjectBlockLocator;
211
- review?: {file: string; keys: ReadonlySet<string>; set(key: string, value: boolean): void};
226
+ review?: {file: string; keys: ReadonlySet<string>; set(key: string, value: boolean): void;
227
+ comments?: {get(key: string): string; set(key: string, value: string, heading: string): void}};
212
228
  }) {
213
229
  const location = useBlockLocation(comparison, locateBlock);
214
230
  return <>
@@ -222,8 +238,10 @@ function ComparisonTargets({comparison, labels, renderBlock, loadAsset, locateBl
222
238
  {target.status === 'updated' && target.properties.length > 0 && <p>{labels.properties} · {propertyNames(target.properties, labels)}</p>}
223
239
  {target.scripts.map(script => {
224
240
  const key = reviewKey(review?.file, target, script);
241
+ const comments = review?.comments;
225
242
  return <ScriptComparison key={key} script={script} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
226
- location={location} reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}/>;
243
+ location={location} reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}
244
+ target={target} comment={comments && {value: comments.get(key), set: (value, heading) => comments.set(key, value, heading)}}/>;
227
245
  })}
228
246
  </section>)}
229
247
  </>;
@@ -383,8 +401,18 @@ const writeMarks = (name: string, marks: Record<string, string>, remember = true
383
401
  markListeners.get(name)?.forEach(listener => listener());
384
402
  };
385
403
 
386
- export function ProjectSourceReview({title, description, verification, files, renderBlock, locale = 'en', rememberAs}: ProjectSourceReviewProps) {
404
+ export function ProjectSourceReview({title, description, verification, files, renderBlock, locale = 'en', rememberAs, commentAs}: ProjectSourceReviewProps) {
387
405
  const labels = locale.startsWith('ja') ? ja : en;
406
+ const draft = useReviewDraft(commentAs);
407
+ const commentsFor = (file: string) => commentAs && draft ? {
408
+ get: (key: string) => draft.comments[key]?.body ?? '',
409
+ set: (key: string, body: string, heading: string) => changeReviewDraft(commentAs, current => {
410
+ const comments = {...current.comments};
411
+ if (body) comments[key] = {file, heading, body};
412
+ else delete comments[key];
413
+ return {...current, comments};
414
+ })
415
+ } : undefined;
388
416
  const entries = useMemo(() => files.map(file => {
389
417
  if (!('patch' in file)) return {file};
390
418
  try {
@@ -457,7 +485,7 @@ export function ProjectSourceReview({title, description, verification, files, re
457
485
  {comparison && <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}
458
486
  loadAsset={'loadAsset' in file ? file.loadAsset : undefined}
459
487
  locateBlock={'locateBlock' in file ? file.locateBlock : undefined}
460
- review={{file: file.path, keys: reviewed, set: setReviewed}}/>}
488
+ review={{file: file.path, keys: reviewed, set: setReviewed, comments: commentsFor(file.path)}}/>}
461
489
  </section>)}
462
490
  </section>;
463
491
  }
@@ -11,6 +11,9 @@ import {GitPanelError} from './filesystem.js';
11
11
  import type {GetRemoteSession} from './remote-operations.js';
12
12
  import type {ProjectSourceReviewFile} from './project-source-review.js';
13
13
  import {pullRequestBranch} from './local-operations.js';
14
+ import {createGitHubReviewApi} from './github-review-api.js';
15
+ import {PullRequestReviewComposer} from './review-composer.js';
16
+ import {reviewLabelsFor} from './messages.js';
14
17
 
15
18
  type OpenTabs = (key: string, files: readonly ProjectSourceTabsFile[], rememberAs?: string) => number;
16
19
 
@@ -87,6 +90,8 @@ export interface PullRequestOpenLocally {
87
90
  export interface PullRequestConnection {
88
91
  getSession: GetRemoteSession;
89
92
  connected(): boolean;
93
+ /** Whether the connection was made with the grant to send reviews; then a review can be sent through it. */
94
+ reviewGranted?(): boolean;
90
95
  }
91
96
 
92
97
  type Result<T> = {data: T; error?: never} | {data?: never; error: string};
@@ -101,12 +106,21 @@ type Pull = {
101
106
  git?: {branches: readonly string[] | null};
102
107
  };
103
108
 
104
- function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs, repository, connection, openLocally}: {
105
- name: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
109
+ function PullRequests({name, remote, labels, locale, renderBlock, locateFor, openTabs, repository, connection, openLocally}: {
110
+ name: string; remote?: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
106
111
  locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
107
112
  openTabs?: OpenTabs; repository: Repository; connection?: PullRequestConnection; openLocally?: PullRequestOpenLocally;
108
113
  }) {
109
114
  const client = useMemo(() => createGitHubPullRequests(name), [name]);
115
+ // Reviews go through the connection's proxy, which keeps the GitHub token.
116
+ const reviewApi = useMemo(() => {
117
+ if (!remote || !connection) return null;
118
+ try {
119
+ return createGitHubReviewApi({remote, getSession: connection.getSession});
120
+ } catch {
121
+ return null;
122
+ }
123
+ }, [remote, connection]);
110
124
  const [page, setPage] = useState(1);
111
125
  const [listing, setListing] = useState<{page: number; result: Result<Listing>}>();
112
126
  const [input, setInput] = useState('');
@@ -173,6 +187,8 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs, r
173
187
  setInput(String(number));
174
188
  };
175
189
  const pr = detail?.data;
190
+ // A review is written and sent for contents read over the connection, when it was made with the review grant.
191
+ const reviewing = !!pr?.git && !!reviewApi && !!connection?.connected() && !!connection.reviewGranted?.();
176
192
  return <div className="twgit-content">
177
193
  <aside>
178
194
  <h3>{labels.title}</h3>
@@ -229,8 +245,13 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs, r
229
245
  {labels.openLocally.replace('{branch}', pullRequestBranch(pr.number))}</button>}
230
246
  </p>
231
247
  {pr.git && <p className="twgit-context" role="status">{labels.viaConnection}{pr.git.branches && <> {labels.noMetadata}</>}</p>}
248
+ {pr.git && connection?.connected() && !connection.reviewGranted?.() &&
249
+ <p className="twgit-context" role="status">{reviewLabelsFor(locale).needsGrant}</p>}
232
250
  <ProjectSourceReview title={pr.title} description={pr.description} files={locateFor ? pr.files.map(file => 'patch' in file ? {...file, locateBlock: locateFor(file.path, pr.head.sha)} : file) : pr.files}
233
- renderBlock={renderBlock} locale={locale} rememberAs={`${name}#${pr.number}`}/>
251
+ renderBlock={renderBlock} locale={locale} rememberAs={`${name}#${pr.number}`}
252
+ commentAs={reviewing ? `${name}#${pr.number}` : undefined}/>
253
+ {reviewing && reviewApi && <PullRequestReviewComposer key={`${name}#${pr.number}`} api={reviewApi} name={`${name}#${pr.number}`}
254
+ number={pr.number} head={pr.head.sha} changedFiles={pr.files.map(file => file.path)} url={pr.url} locale={locale}/>}
234
255
  </>}
235
256
  </main>
236
257
  </div>;
@@ -254,11 +275,11 @@ export function PullRequestReviewPanel({repository, locale, renderBlock, locateF
254
275
  locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
255
276
  }) {
256
277
  const labels = locale.startsWith('ja') ? ja : en;
257
- const [origin, setOrigin] = useState<{repository: Repository; name: string | null; failed?: boolean}>();
278
+ const [origin, setOrigin] = useState<{repository: Repository; name: string | null; remote?: string; failed?: boolean}>();
258
279
  useEffect(() => {
259
280
  let active = true;
260
281
  void repository.readOrigin().then(value => {
261
- if (active) setOrigin({repository, name: gitHubRepositoryFromOrigin(value)});
282
+ if (active) setOrigin({repository, name: gitHubRepositoryFromOrigin(value), remote: value});
262
283
  }, () => {
263
284
  if (active) setOrigin({repository, name: null, failed: true});
264
285
  });
@@ -266,6 +287,6 @@ export function PullRequestReviewPanel({repository, locale, renderBlock, locateF
266
287
  }, [repository]);
267
288
  if (origin?.repository !== repository) return <p role="status" className="twgit-context">{labels.loading}</p>;
268
289
  if (!origin.name) return <p className="twgit-context" role="status">{origin.failed ? labels.failed : labels.noOrigin}</p>;
269
- return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor} openTabs={openTabs}
290
+ return <PullRequests key={origin.name} name={origin.name} remote={origin.remote} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor} openTabs={openTabs}
270
291
  repository={repository} connection={connection} openLocally={openLocally}/>;
271
292
  }
@@ -6,7 +6,7 @@ import type {GitSmartHttpOptions} from './smart-http.js';
6
6
 
7
7
  export type RemoteOperation = 'fetch' | 'pull' | 'push';
8
8
  export type RemoteSession = NonNullable<Awaited<ReturnType<GitSmartHttpOptions['getSession']>>>;
9
- export type GetRemoteSession = (remote: string, write: boolean) => Promise<RemoteSession | null>;
9
+ export type GetRemoteSession = (remote: string, write: boolean, review?: boolean) => Promise<RemoteSession | null>;
10
10
 
11
11
  type GitOptions = {fs: ReturnType<typeof createMutationFs>['fs']; dir: string; gitdir: string; cache: object};
12
12
  const sessionCodes = ['SESSION_REQUIRED', 'SESSION_UNAVAILABLE', 'SESSION_EXPIRED', 'REAUTH_REQUIRED', 'INVALID_SESSION'];
@@ -0,0 +1,166 @@
1
+ import React, {useEffect, useRef, useState} from 'react';
2
+ import {CloseButton} from './close-button.js';
3
+ import {reviewEvents} from './github-review-api.js';
4
+ import type {GitHubReviewApi} from './github-review-api.js';
5
+ import {reviewLabelsFor} from './messages.js';
6
+ import type {ReviewLabels} from './messages.js';
7
+ import {
8
+ checkReview, discardReview, forgetReviewSubmission, planReview, probeReviewRoutes, readReviewDraft, reviewDraftPersistent,
9
+ reviewProblem, reviewRoute, sendReview, subscribeReviewDraft, writeReviewDraft
10
+ } from './review-submit.js';
11
+ import type {ReviewDraft, ReviewResult} from './review-submit.js';
12
+
13
+ /** The saved draft of a review, updated when any view of the same review writes it. */
14
+ export function useReviewDraft(name: string | undefined): ReviewDraft | null {
15
+ const [, refresh] = useState(0);
16
+ useEffect(() => name ? subscribeReviewDraft(name, () => refresh(count => count + 1)) : undefined, [name]);
17
+ return name ? readReviewDraft(name) : null;
18
+ }
19
+
20
+ /** Changes the saved draft from what is stored now, so other views' words are kept. */
21
+ export function changeReviewDraft(name: string, change: (draft: ReviewDraft) => ReviewDraft) {
22
+ writeReviewDraft(name, change(readReviewDraft(name)));
23
+ }
24
+
25
+ const sessionCodes = ['SESSION_REQUIRED', 'SESSION_EXPIRED', 'REAUTH_REQUIRED', 'SESSION_UNAVAILABLE', 'INVALID_SESSION'];
26
+
27
+ function resultMessage(result: ReviewResult, labels: ReviewLabels) {
28
+ const code = result.code;
29
+ if (code === 'RATE_LIMITED') return labels.RATE_LIMITED.replace('{seconds}', String(result.retryAfter ?? 60));
30
+ if (result.outcome === 'submitted') return labels.sent;
31
+ if (result.outcome === 'closed') return labels.closed;
32
+ if (result.outcome === 'lost') return labels.lost.replace('{count}', String(result.reviews?.length ?? 0));
33
+ if (result.outcome === 'left') return labels.PENDING_REVIEW_LEFT;
34
+ if (result.outcome === 'unknown') return labels.REVIEW_OUTCOME_UNKNOWN;
35
+ if (code && sessionCodes.includes(code)) return labels.session;
36
+ if (code === 'PENDING_REVIEW_EXISTS' || code === 'REVIEW_REJECTED' || code === 'REVIEW_THREAD_REJECTED' || code === 'EMPTY_REVIEW' ||
37
+ code === 'TOO_LONG' || code === 'FORBIDDEN' || code === 'GITHUB_API_UNAVAILABLE') {
38
+ const message = labels[code];
39
+ return result.outcome === 'pending' ? labels.pending + ' ' + message : message;
40
+ }
41
+ if (code === 'UNSUPPORTED') return labels.unsupported;
42
+ if (result.outcome === 'pending') return labels.pending;
43
+ if (result.outcome === 'not-sent' && !code) return labels.discarded;
44
+ return labels.failed;
45
+ }
46
+
47
+ /**
48
+ * The overall comment, the result, how the code comments will be sent, and the send button of a pull request review.
49
+ * The code comments are written under each code change of the review that shares `name`.
50
+ */
51
+ export function PullRequestReviewComposer({api, name, number, head, changedFiles, url, locale}: {
52
+ api: GitHubReviewApi;
53
+ /** Where the draft is kept, such as `owner/repo#42`. */
54
+ name: string;
55
+ number: number;
56
+ /** The compared head commit. */
57
+ head: string;
58
+ changedFiles: readonly string[];
59
+ /** The pull request on GitHub. */
60
+ url: string;
61
+ locale: string;
62
+ }) {
63
+ const labels = reviewLabelsFor(locale);
64
+ const draft = useReviewDraft(name)!;
65
+ const [route, setRoute] = useState(() => reviewRoute(api));
66
+ const [busy, setBusy] = useState<'send' | 'check' | null>(null);
67
+ const [result, setResult] = useState<ReviewResult | null>(null);
68
+ const [blocked, setBlocked] = useState(false);
69
+ const active = useRef(true);
70
+ useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
71
+ const target = {api, name, number, head, changedFiles};
72
+
73
+ const run = async (kind: 'send' | 'check', action: () => Promise<ReviewResult>) => {
74
+ if (busy || blocked) return;
75
+ setBusy(kind);
76
+ setResult(null);
77
+ let next: ReviewResult;
78
+ try {
79
+ next = await action();
80
+ } catch {
81
+ next = {outcome: 'not-sent', code: 'NETWORK_ERROR'};
82
+ }
83
+ if (!active.current) return;
84
+ setBusy(null);
85
+ setRoute(reviewRoute(api));
86
+ if (next.code === 'BUSY') return;
87
+ setResult(next);
88
+ if (next.retryAfter) {
89
+ setBlocked(true);
90
+ setTimeout(() => {if (active.current) setBlocked(false);}, next.retryAfter * 1000);
91
+ }
92
+ };
93
+
94
+ // Ask once which routes the connection offers, and settle a submission whose outcome was unknown when the page closed.
95
+ useEffect(() => {
96
+ const controller = new AbortController();
97
+ void (async () => {
98
+ await probeReviewRoutes(api, number, controller.signal);
99
+ if (controller.signal.aborted) return;
100
+ setRoute(reviewRoute(api));
101
+ const state = readReviewDraft(name).submit?.state;
102
+ if (state === 'start-unknown' || state === 'submit-unknown') await run('check', () => checkReview(target));
103
+ })();
104
+ return () => controller.abort();
105
+ }, [api, name, number]);
106
+ // The comments were written for the head they were first written on.
107
+ const hasWords = !!draft.body || Object.values(draft.comments).some(comment => comment.body);
108
+ useEffect(() => {
109
+ if (hasWords && !draft.head) changeReviewDraft(name, current => ({...current, head}));
110
+ }, [hasWords, draft.head, head, name]);
111
+
112
+ const submit = draft.submit;
113
+ const plan = planReview(draft, {threads: route === 'threads' && submit?.state !== 'pending', changedFiles, exclude: submit?.threadKeys});
114
+ const threads = submit?.state === 'pending' ? submit.threadKeys.length : route === 'threads' ? plan.threads.length : 0;
115
+ const comments = (submit?.state === 'pending' ? submit.threadKeys.length : 0) + plan.threads.length + plan.inBody.length;
116
+ const problem = route && reviewProblem(draft.event, {...plan, threads: Array.from({length: threads}, () => ({key: '', path: '', body: ''}))},
117
+ route);
118
+ const disabled = !!busy || blocked;
119
+ const message = result && resultMessage(result, labels);
120
+ const failed = result && result.outcome !== 'submitted' && !(result.outcome === 'not-sent' && !result.code);
121
+ const pullLink = <a href={url} target="_blank" rel="noreferrer">{labels.openPullRequest}</a>;
122
+
123
+ return <section className="twgit-review-composer" aria-label={labels.composer} data-git-review-composer="">
124
+ <h3>{labels.composer}</h3>
125
+ {!reviewDraftPersistent() && <p role="status" className="twgit-review-note">{labels.notStored}</p>}
126
+ {draft.head && draft.head !== head && <p role="status" className="twgit-review-note">{labels.headChanged}{' '}
127
+ <button type="button" onClick={() => changeReviewDraft(name, current => ({...current, head}))}>{labels.useVersion}</button></p>}
128
+ <label className="twgit-review-field">{labels.overall}
129
+ <textarea value={draft.body} rows={4} onChange={event => {
130
+ const body = event.currentTarget.value;
131
+ changeReviewDraft(name, current => ({...current, body}));
132
+ }}/></label>
133
+ <fieldset className="twgit-review-events">
134
+ <legend>{labels.event}</legend>
135
+ {reviewEvents.map(event => <label key={event}><input type="radio" name={`twgit-review-event-${name}`} value={event}
136
+ checked={draft.event === event} onChange={() => changeReviewDraft(name, current => ({...current, event}))}/> {labels[event]}</label>)}
137
+ </fieldset>
138
+ {route === null ? <p role="status">{labels.unsupported}</p> : <>
139
+ {(!submit || submit.state === 'pending') && <p className="twgit-review-summary" data-git-review-summary="">{!comments ? labels.summaryNone :
140
+ route === 'threads' ? labels.summaryThreads.replace('{threads}', String(threads)).replace('{body}', String(plan.inBody.length)) :
141
+ labels.summaryBody.replace('{body}', String(plan.inBody.length))}</p>}
142
+ <div className="twgit-review-actions">
143
+ {(!submit || submit.state === 'pending') && <button type="button" data-git-review-send="" disabled={disabled || !!problem}
144
+ title={problem ? resultMessage({outcome: 'not-sent', code: problem}, labels) : undefined}
145
+ onClick={() => void run('send', () => sendReview(target))}>{submit ? labels.sendAgain : labels.send}</button>}
146
+ {(submit?.state === 'start-unknown' || submit?.state === 'submit-unknown') &&
147
+ <button type="button" disabled={disabled} onClick={() => void run('check', () => checkReview(target))}>{labels.check}</button>}
148
+ {(submit?.state === 'pending' || submit?.state === 'left') &&
149
+ <button type="button" disabled={disabled} onClick={() => void run('send', () => discardReview(target))}>{labels.discard}</button>}
150
+ {(submit?.state === 'left' || result?.outcome === 'lost') && <>
151
+ {pullLink}
152
+ <button type="button" disabled={disabled} onClick={() => {forgetReviewSubmission(name); setResult(null);}}>{labels.forget}</button>
153
+ </>}
154
+ </div>
155
+ {busy && <p role="status">{busy === 'send' ? labels.sending : labels.checking}</p>}
156
+ {!busy && !result && submit && <p role="status" className="twgit-review-state">
157
+ {submit.state === 'pending' ? labels.pending : submit.state === 'left' ? labels.PENDING_REVIEW_LEFT : labels.REVIEW_OUTCOME_UNKNOWN}</p>}
158
+ {message && result && <div className={failed ? 'twgit-review-result twgit-error' : 'twgit-review-result twgit-context'}
159
+ role={failed ? 'alert' : 'status'}>
160
+ <p>{message}{result.code === 'PENDING_REVIEW_EXISTS' && <> {pullLink}</>}
161
+ {result.outcome === 'submitted' && result.url && <> <a href={result.url} target="_blank" rel="noreferrer">{labels.openReview}</a></>}</p>
162
+ <CloseButton label={labels.dismiss} onClick={() => setResult(null)}/>
163
+ </div>}
164
+ </>}
165
+ </section>;
166
+ }