@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
@@ -0,0 +1,298 @@
1
+ import type {GitProxySession} from '@kubohiroya/turbowarp-plus-editor-auth';
2
+ import {checkConnection, remoteUrl} from './smart-http.js';
3
+
4
+ /**
5
+ * The pull request review routes of the GitHub connection's proxy, under `/v1/github/<owner>/<repo>/`. The proxy keeps
6
+ * the GitHub token and the GitHub node IDs; this client sends only a pull request number, the compared commit, a
7
+ * Review-Submit-Id and the review's words, and reads back the few fields the proxy returns.
8
+ */
9
+
10
+ export type ReviewEvent = 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES';
11
+ export const reviewEvents: readonly ReviewEvent[] = ['COMMENT', 'APPROVE', 'REQUEST_CHANGES'];
12
+
13
+ const proxyCodes = {
14
+ 400: ['BAD_REQUEST'],
15
+ 401: ['SESSION_REQUIRED', 'SESSION_EXPIRED', 'REAUTH_REQUIRED'],
16
+ 403: ['FORBIDDEN'],
17
+ // A proxy without the route group answers 404 too, so an unnamed 404 means the route is unavailable.
18
+ 404: ['GITHUB_API_UNAVAILABLE', 'PULL_REQUEST_NOT_FOUND'],
19
+ 409: ['REVIEW_STEP_CONFLICT', 'REVIEW_DUPLICATE', 'PENDING_REVIEW_EXISTS', 'REDIRECT_REJECTED'],
20
+ 411: ['LENGTH_REQUIRED'],
21
+ 413: ['PAYLOAD_TOO_LARGE'],
22
+ 422: ['REVIEW_REJECTED', 'REVIEW_THREAD_REJECTED'],
23
+ 429: ['RATE_LIMITED'],
24
+ 500: ['GITHUB_API_MISCONFIGURED'],
25
+ 502: ['UPSTREAM_FAILURE'],
26
+ 504: ['UPSTREAM_TIMEOUT']
27
+ } as const;
28
+
29
+ type ProxyCode = (typeof proxyCodes)[keyof typeof proxyCodes][number];
30
+ export type GitHubReviewErrorCode = ProxyCode | 'INVALID_REMOTE' | 'INVALID_CONNECTION' | 'INVALID_SESSION' |
31
+ 'SESSION_UNAVAILABLE' | 'NETWORK_ERROR' | 'INVALID_RESPONSE' | 'ABORTED';
32
+
33
+ // Codes after which a mutation may or may not have reached GitHub.
34
+ const unknownCodes: readonly GitHubReviewErrorCode[] = ['REVIEW_DUPLICATE', 'UPSTREAM_FAILURE', 'UPSTREAM_TIMEOUT',
35
+ 'NETWORK_ERROR', 'INVALID_RESPONSE'];
36
+
37
+ export class GitHubReviewError extends Error {
38
+ constructor(readonly code: GitHubReviewErrorCode, readonly options: {
39
+ status?: number; requestId?: string; retryAfter?: number;
40
+ /** A mutation was sent and GitHub may have applied it. */
41
+ unknown?: boolean;
42
+ } = {}) {
43
+ super(code);
44
+ this.name = 'GitHubReviewError';
45
+ }
46
+ get unknown() {return this.options.unknown === true;}
47
+ get retryAfter() {return this.options.retryAfter;}
48
+ }
49
+
50
+ const isSha = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{40}$/u.test(value);
51
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
52
+ const isCount = (value: unknown, max: number): value is number => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) <= max;
53
+ const optionalString = (value: unknown) => value === null || typeof value === 'string';
54
+ const submitIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
55
+ const requestIdPattern = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu;
56
+
57
+ /** A thread path the proxy accepts: a relative path whose last element is exactly project.source.json. */
58
+ export function isReviewThreadPath(path: string) {
59
+ const parts = path.split('/');
60
+ return parts[parts.length - 1] === 'project.source.json' && new TextEncoder().encode(path).byteLength <= 1024 &&
61
+ parts.every(part => part && part !== '.' && part !== '..') && !/[\\\u0000-\u001f\u007f]/u.test(path);
62
+ }
63
+
64
+ export function newReviewSubmitId() {
65
+ const id = crypto.randomUUID();
66
+ if (!submitIdPattern.test(id)) throw new GitHubReviewError('INVALID_RESPONSE');
67
+ return id;
68
+ }
69
+
70
+ export interface ReviewContext {
71
+ headRefOid: string;
72
+ viewer: string;
73
+ pendingReview: {ours: boolean; commitOid: string | null; createdAt: string} | null;
74
+ reviews: {state: string; commitOid: string | null; submittedAt: string | null; ours: boolean}[];
75
+ /** The proxy's record of the submission, when the request named one. */
76
+ submit?: {state: string; threads: number};
77
+ }
78
+
79
+ export interface SubmittedReview {state: string; commitOid: string; submittedAt: string; url: string}
80
+ export interface RestReview {id: number; login: string | null; commitId: string | null; state: string; body: string}
81
+
82
+ function readContext(value: unknown, withSubmit: boolean): ReviewContext {
83
+ if (!isRecord(value) || !isSha(value.headRefOid) || typeof value.viewer !== 'string' || !Array.isArray(value.reviews))
84
+ throw new GitHubReviewError('INVALID_RESPONSE');
85
+ let pendingReview: ReviewContext['pendingReview'] = null;
86
+ if (value.pendingReview !== null) {
87
+ const pending = value.pendingReview;
88
+ if (!isRecord(pending) || typeof pending.ours !== 'boolean' || !(pending.commitOid === null || isSha(pending.commitOid)) ||
89
+ typeof pending.createdAt !== 'string') throw new GitHubReviewError('INVALID_RESPONSE');
90
+ pendingReview = {ours: pending.ours, commitOid: pending.commitOid, createdAt: pending.createdAt};
91
+ }
92
+ const reviews = value.reviews.map(review => {
93
+ if (!isRecord(review) || typeof review.state !== 'string' || !(review.commitOid === null || isSha(review.commitOid)) ||
94
+ !optionalString(review.submittedAt) || typeof review.ours !== 'boolean') throw new GitHubReviewError('INVALID_RESPONSE');
95
+ return {state: review.state, commitOid: review.commitOid as string | null, submittedAt: review.submittedAt as string | null,
96
+ ours: review.ours};
97
+ });
98
+ const context: ReviewContext = {headRefOid: value.headRefOid, viewer: value.viewer, pendingReview, reviews};
99
+ if (withSubmit) {
100
+ const submit = value.submit;
101
+ if (!isRecord(submit) || typeof submit.state !== 'string' || !isCount(submit.threads, 50))
102
+ throw new GitHubReviewError('INVALID_RESPONSE');
103
+ context.submit = {state: submit.state, threads: submit.threads};
104
+ }
105
+ return context;
106
+ }
107
+
108
+ async function readBounded(response: Response, limit: number) {
109
+ if (!response.body) return '';
110
+ const reader = response.body.getReader();
111
+ const decoder = new TextDecoder('utf-8', {fatal: true});
112
+ let text = '', size = 0;
113
+ try {
114
+ for (;;) {
115
+ const {done, value} = await reader.read();
116
+ if (done) return text + decoder.decode();
117
+ size += value.byteLength;
118
+ if (size > limit) throw new GitHubReviewError('INVALID_RESPONSE');
119
+ text += decoder.decode(value, {stream: true});
120
+ }
121
+ } finally {
122
+ void reader.cancel().catch(() => {});
123
+ }
124
+ }
125
+
126
+ export interface GitHubReviewApiOptions {
127
+ /** The repository's GitHub HTTPS remote, as the connection was made for it. */
128
+ readonly remote: string;
129
+ /** The connection's session for `remote`; review calls ask for the review grant. */
130
+ readonly getSession: (remote: string, write: boolean, review?: boolean) => Promise<GitProxySession | null>;
131
+ readonly fetch?: typeof fetch;
132
+ }
133
+
134
+ export function createGitHubReviewApi({remote, getSession, fetch: send = (...args) => fetch(...args)}: GitHubReviewApiOptions) {
135
+ let path: string;
136
+ try {
137
+ path = '/v1/github' + remoteUrl(remote).slice('https://github.com'.length).replace(/\.git$/u, '');
138
+ } catch {
139
+ throw new GitHubReviewError('INVALID_REMOTE');
140
+ }
141
+ /** What the proxy answered about each route group: `false` after `404 GITHUB_API_UNAVAILABLE`. */
142
+ const available: {threads?: boolean; body?: boolean} = {};
143
+ let account: string | null = null;
144
+
145
+ async function session(review: boolean) {
146
+ let value: GitProxySession | null;
147
+ try {
148
+ value = await getSession(remote, false, review);
149
+ } catch {
150
+ throw new GitHubReviewError('SESSION_UNAVAILABLE');
151
+ }
152
+ if (!value) throw new GitHubReviewError('SESSION_REQUIRED');
153
+ if (!Number.isFinite(value.expiresAt) || value.expiresAt <= Date.now()) throw new GitHubReviewError('SESSION_EXPIRED');
154
+ try {
155
+ checkConnection(value.connection);
156
+ } catch {
157
+ throw new GitHubReviewError('INVALID_CONNECTION');
158
+ }
159
+ if (typeof value.bearer !== 'string' || !/^[A-Za-z0-9._~+\/-]+=*$/u.test(value.bearer))
160
+ throw new GitHubReviewError('INVALID_SESSION');
161
+ account = typeof value.account?.login === 'string' ? value.account.login : null;
162
+ return value;
163
+ }
164
+
165
+ async function call(route: string, {method = 'GET', body, submitId, review = false, mutation = false, limit = 256 * 1024,
166
+ group, signal}: {
167
+ method?: 'GET' | 'POST'; body?: unknown; submitId?: string; review?: boolean; mutation?: boolean; limit?: number;
168
+ group: 'threads' | 'body' | 'read'; signal?: AbortSignal;
169
+ }): Promise<unknown> {
170
+ if (signal?.aborted) throw new GitHubReviewError('ABORTED');
171
+ const current = await session(review);
172
+ const headers: Record<string, string> = {Authorization: 'Bearer ' + current.bearer, Accept: 'application/json'};
173
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
174
+ if (submitId !== undefined) {
175
+ if (!submitIdPattern.test(submitId)) throw new GitHubReviewError('BAD_REQUEST');
176
+ headers['Review-Submit-Id'] = submitId;
177
+ }
178
+ let response: Response;
179
+ try {
180
+ response = await send(current.connection.proxyOrigin + path + route, {
181
+ method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal,
182
+ credentials: 'omit', redirect: 'error', cache: 'no-store', referrerPolicy: 'no-referrer'
183
+ });
184
+ } catch {
185
+ if (!mutation && signal?.aborted) throw new GitHubReviewError('ABORTED');
186
+ throw new GitHubReviewError('NETWORK_ERROR', {unknown: mutation});
187
+ }
188
+ const json = response.headers.get('content-type')?.split(';')[0].trim() === 'application/json';
189
+ if (response.status !== 200) {
190
+ const allowed: readonly ProxyCode[] | undefined = proxyCodes[response.status as keyof typeof proxyCodes];
191
+ let code: GitHubReviewErrorCode = allowed?.[0] ?? 'INVALID_RESPONSE';
192
+ let requestId: string | undefined, retryAfter: number | undefined;
193
+ try {
194
+ const parsed: unknown = json ? JSON.parse(await readBounded(response, 4096)) : null;
195
+ if (isRecord(parsed)) {
196
+ if (allowed?.includes(parsed.code as ProxyCode)) code = parsed.code as ProxyCode;
197
+ if (typeof parsed.requestId === 'string' && requestIdPattern.test(parsed.requestId)) requestId = parsed.requestId;
198
+ if (Number.isSafeInteger(parsed.retryAfter) && Number(parsed.retryAfter) >= 0) retryAfter = Number(parsed.retryAfter);
199
+ }
200
+ } catch {
201
+ // An untrusted error body never becomes part of a client error.
202
+ }
203
+ if (code === 'GITHUB_API_UNAVAILABLE' && group !== 'read') available[group] = false;
204
+ if (code === 'GITHUB_API_UNAVAILABLE' && group === 'read') available.threads = false;
205
+ const unknown = mutation && (unknownCodes.includes(code) || !allowed);
206
+ throw new GitHubReviewError(unknown && !allowed ? 'UPSTREAM_FAILURE' : code,
207
+ {status: response.status, requestId, retryAfter, unknown});
208
+ }
209
+ if (group !== 'read') available[group] = true;
210
+ try {
211
+ if (!json) throw new Error();
212
+ return JSON.parse(await readBounded(response, limit));
213
+ } catch {
214
+ throw new GitHubReviewError('INVALID_RESPONSE', {unknown: mutation});
215
+ }
216
+ }
217
+
218
+ const checkNumber = (number: number) => {
219
+ if (!Number.isSafeInteger(number) || number < 1 || number > 2147483647) throw new GitHubReviewError('BAD_REQUEST');
220
+ };
221
+ const graphql = (operation: string, number: number, variables: Record<string, unknown>, submitId: string | undefined,
222
+ signal: AbortSignal | undefined, mutation = true) => {
223
+ checkNumber(number);
224
+ return call('/graphql/' + operation, {method: 'POST', body: {number, ...variables}, submitId, review: mutation, mutation,
225
+ group: mutation ? 'threads' : 'read', signal});
226
+ };
227
+ const shape = <T>(check: boolean, value: T, mutation = true): T => {
228
+ if (!check) throw new GitHubReviewError('INVALID_RESPONSE', {unknown: mutation});
229
+ return value;
230
+ };
231
+
232
+ return {
233
+ available,
234
+ /** The GitHub login of the last session this client used. */
235
+ account: () => account,
236
+ /** Reads the viewer's pending and recent reviews; with `submitId`, resolves that submission's unknown outcome. */
237
+ async context(number: number, submitId?: string, signal?: AbortSignal) {
238
+ const value = await graphql('pullRequestReviewContext', number, {}, submitId, signal, false);
239
+ const context = readContext(value, submitId !== undefined);
240
+ available.threads = true;
241
+ return context;
242
+ },
243
+ /** Step 1: a pending review on `commitOid`. */
244
+ async start(number: number, submitId: string, commitOid: string, signal?: AbortSignal) {
245
+ if (!isSha(commitOid)) throw new GitHubReviewError('BAD_REQUEST');
246
+ const value = await graphql('addPullRequestReview', number, {commitOid}, submitId, signal);
247
+ shape(isRecord(value) && value.state === 'PENDING' && value.commitOid === commitOid, value);
248
+ },
249
+ /** Step 2: one file-level thread on a project.source.json; resolves the number of threads the proxy has accepted. */
250
+ async thread(number: number, submitId: string, threadIndex: number, threadPath: string, body: string, signal?: AbortSignal) {
251
+ if (!isCount(threadIndex, 49) || !isReviewThreadPath(threadPath) || !body || body.length > 65536)
252
+ throw new GitHubReviewError('BAD_REQUEST');
253
+ const value = await graphql('addPullRequestReviewThread', number, {threadIndex, path: threadPath, body}, submitId, signal);
254
+ return shape(isRecord(value) && value.threads === threadIndex + 1, threadIndex + 1);
255
+ },
256
+ /** Step 3: publishes the pending review. */
257
+ async submit(number: number, submitId: string, threadCount: number, event: ReviewEvent, body: string, signal?: AbortSignal) {
258
+ if (!isCount(threadCount, 50) || !reviewEvents.includes(event) || body.length > 65536)
259
+ throw new GitHubReviewError('BAD_REQUEST');
260
+ const value = await graphql('submitPullRequestReview', number, {threadCount, event, body}, submitId, signal);
261
+ const review = (isRecord(value) ? value : {}) as Partial<Record<keyof SubmittedReview, unknown>>;
262
+ return shape(typeof review.state === 'string' && review.state !== 'PENDING' && isSha(review.commitOid) &&
263
+ typeof review.submittedAt === 'string' && typeof review.url === 'string', review as SubmittedReview);
264
+ },
265
+ /** Removes the pending review of the submission. */
266
+ async discard(number: number, submitId: string, signal?: AbortSignal) {
267
+ const value = await graphql('deletePullRequestReview', number, {}, submitId, signal);
268
+ shape(isRecord(value) && value.deleted === true, value);
269
+ },
270
+ /** The review-body route: one page of the pull request's reviews, oldest first. */
271
+ async reviews(number: number, page: number, signal?: AbortSignal): Promise<RestReview[]> {
272
+ checkNumber(number);
273
+ if (!Number.isSafeInteger(page) || page < 1 || page > 10) throw new GitHubReviewError('BAD_REQUEST');
274
+ const value = await call(`/pulls/${number}/reviews?page=${page}`, {group: 'body', limit: 4 * 1024 * 1024, signal});
275
+ if (!Array.isArray(value)) throw new GitHubReviewError('INVALID_RESPONSE');
276
+ return value.map(review => {
277
+ if (!isRecord(review) || !Number.isSafeInteger(review.id) || typeof review.state !== 'string' ||
278
+ !(review.commit_id === null || isSha(review.commit_id)) || !optionalString(review.body))
279
+ throw new GitHubReviewError('INVALID_RESPONSE');
280
+ const login = isRecord(review.user) && typeof review.user.login === 'string' ? review.user.login : null;
281
+ return {id: Number(review.id), login, commitId: review.commit_id as string | null, state: review.state,
282
+ body: typeof review.body === 'string' ? review.body : ''};
283
+ });
284
+ },
285
+ /** The review-body route: creates and publishes a review with a body only. */
286
+ async create(number: number, submitId: string, commitOid: string, event: ReviewEvent, body: string, signal?: AbortSignal) {
287
+ checkNumber(number);
288
+ if (!isSha(commitOid) || !reviewEvents.includes(event) || body.length > 65536 || (!body && event !== 'APPROVE'))
289
+ throw new GitHubReviewError('BAD_REQUEST');
290
+ const value = await call(`/pulls/${number}/reviews`, {method: 'POST', body: {commit_id: commitOid, event, body},
291
+ submitId, review: true, mutation: true, group: 'body', limit: 1024 * 1024, signal});
292
+ return shape(isRecord(value) && typeof value.state === 'string' && typeof value.html_url === 'string',
293
+ {url: (value as {html_url: string}).html_url});
294
+ }
295
+ };
296
+ }
297
+
298
+ export type GitHubReviewApi = ReturnType<typeof createGitHubReviewApi>;
package/src/index.tsx CHANGED
@@ -6,7 +6,9 @@ import {findElement, ProjectChangeListView, ProjectElementView, projectChangeLab
6
6
  import type {ChangedFile} from './project-change-model.js';
7
7
  import {openRepository} from './repository.js';
8
8
  import type {BranchRefs, Repository, Snapshot, FileChange, FileDiff, ChangeGroup, HistoryCommit, HistoryCursor} from './repository.js';
9
- import {BranchButton} from './branch-button.js';
9
+ import {BranchButton, useBranchSnackbar} from './branch-button.js';
10
+ import {changeMarks} from './change-marks.js';
11
+ import type {ChangeMark} from './change-marks.js';
10
12
  import type {BranchButtonState} from './branch-button.js';
11
13
  import {HistoryGraph} from './history-graph-view.js';
12
14
  import type {HistoryActionResult} from './history-graph-view.js';
@@ -43,6 +45,8 @@ export {HistoryGraph, laneColors} from './history-graph-view.js';
43
45
  export type {HistoryActionResult, HistoryGraphProps} from './history-graph-view.js';
44
46
  export {layoutHistory} from './history-graph.js';
45
47
  export type {GraphCommit, GraphLayout, GraphRow, GraphSegment} from './history-graph.js';
48
+ export {changeMarks} from './change-marks.js';
49
+ export type {ChangeMark} from './change-marks.js';
46
50
  export type {GitAuthor, LocalOperation} from './local-operations.js';
47
51
  export {pullRequestBranch} from './local-operations.js';
48
52
 
@@ -89,9 +93,14 @@ interface Controller {
89
93
  /** Asks for a repository, then opens the panel on it. */
90
94
  choose(locale: string): Promise<void>;
91
95
  changeBranch(operation: BranchOperation): Promise<HistoryActionResult>;
96
+ /** Reloads the project folder selected in the panel, as Reload Project does. */
97
+ reload(): void;
92
98
  /** What the branch buttons show. */
93
99
  branch: BranchButtonState;
94
- publish(state: BranchButtonState): void;
100
+ /** The chosen repository and its last snapshot, published with `branch`. */
101
+ repository: Repository | null;
102
+ snapshot: Snapshot | null;
103
+ publish(state: BranchButtonState, repository: Repository | null, snapshot: Snapshot | null): void;
95
104
  /** Follows `branch`. While anything follows it, the closed panel keeps watching the branches. */
96
105
  follow(listener: () => void): () => void;
97
106
  /** How many follow `branch`. */
@@ -102,10 +111,15 @@ interface Controller {
102
111
  function createController(): Controller {
103
112
  const listeners = new Set<() => void>();
104
113
  const controller: Controller = {
105
- open() {}, async refresh() {}, async choose() {}, async changeBranch() {return {done: false};},
106
- branch: {repository: false, branch: undefined, head: null, branches: [], remoteBranches: [], writable: false, busy: false},
107
- publish(state) {
114
+ open() {}, async refresh() {}, async choose() {}, async changeBranch() {return {done: false};}, reload() {},
115
+ branch: {repository: false, branch: undefined, head: null, branches: [], remoteBranches: [], writable: false, busy: false,
116
+ project: false},
117
+ repository: null,
118
+ snapshot: null,
119
+ publish(state, repository, snapshot) {
108
120
  controller.branch = state;
121
+ controller.repository = repository;
122
+ controller.snapshot = snapshot;
109
123
  for (const listener of [...listeners]) listener();
110
124
  },
111
125
  follow(listener) {
@@ -450,7 +464,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
450
464
  const pullRequestConnection = useMemo(() => {
451
465
  const settings = host.connectionSettings;
452
466
  const getSession = settings?.getSession;
453
- return getSession ? {getSession, connected: () => settings.getView().state.status === 'connected'} : undefined;
467
+ return getSession ? {getSession, connected: () => settings.getView().state.status === 'connected',
468
+ reviewGranted: () => settings.getView().review === true} : undefined;
454
469
  }, [host]);
455
470
  const [message, setMessage] = useState('');
456
471
  const localOperation = (operation: LocalOperation, notice?: string) => {
@@ -552,6 +567,9 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
552
567
  const loadAsset = useMemo(() => state.repository && state.snapshot && state.selected ?
553
568
  state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
554
569
  controller.changeBranch = changeBranch;
570
+ controller.reload = () => {
571
+ if (current.current.project !== null) void openProject(current.current.project);
572
+ };
555
573
  controller.choose = async language => {
556
574
  setLocale(language);
557
575
  const before = current.current.repository;
@@ -560,9 +578,10 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
560
578
  if (!result.done && result.error || current.current.repository !== before) controller.open(language);
561
579
  };
562
580
  const branchState: BranchButtonState = {repository: !!state.repository, branch: refs?.branch, head: refs?.head ?? null,
563
- branches: refs?.branches ?? [], remoteBranches: refs?.remoteBranches ?? [], writable: !!host.localWrite && !!refs, busy: state.busy};
581
+ branches: refs?.branches ?? [], remoteBranches: refs?.remoteBranches ?? [], writable: !!host.localWrite && !!refs, busy: state.busy,
582
+ project: state.project !== null};
564
583
  const published = JSON.stringify(branchState);
565
- useEffect(() => controller.publish(branchState), [published]);
584
+ useEffect(() => controller.publish(branchState, state.repository, state.snapshot), [published, state.repository, state.snapshot]);
566
585
  if (!shown) return null;
567
586
  const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
568
587
  // Branches can be switched and created whenever writes are allowed and the repository has been read.
@@ -768,14 +787,18 @@ export function mountGitPanel({vm, document, host}: {vm: object; document: Docum
768
787
  }
769
788
 
770
789
  /** The branch button of the panel mounted for `vm`, or nothing while no panel is mounted for it. */
771
- function BranchEntry({vm, locale}: {vm: object; locale: string}) {
790
+ /** Calls `listener` with the controller of the panel mounted for `vm`, now and whenever it is mounted or disposed. */
791
+ function findController(vm: object, listener: (controller: Controller | undefined) => void) {
792
+ const update = () => listener(controllers.get(vm));
793
+ registrations.add(update);
794
+ update();
795
+ return () => {registrations.delete(update);};
796
+ }
797
+
798
+ /** The panel mounted for `vm` and what it last published. Following it keeps the closed panel watching the branches. */
799
+ function useController(vm: object) {
772
800
  const [controller, setController] = useState(() => controllers.get(vm));
773
- useEffect(() => {
774
- const update = () => setController(controllers.get(vm));
775
- registrations.add(update);
776
- update();
777
- return () => {registrations.delete(update);};
778
- }, [vm]);
801
+ useEffect(() => findController(vm, setController), [vm]);
779
802
  const [state, setState] = useState(controller?.branch);
780
803
  useEffect(() => {
781
804
  if (!controller) return;
@@ -783,8 +806,14 @@ function BranchEntry({vm, locale}: {vm: object; locale: string}) {
783
806
  update();
784
807
  return controller.follow(update);
785
808
  }, [controller]);
809
+ return {controller, state};
810
+ }
811
+
812
+ function BranchEntry({vm, locale}: {vm: object; locale: string}) {
813
+ const {controller, state} = useController(vm);
786
814
  if (!controller || !state) return null;
787
815
  return <BranchButton state={state} locale={locale} open={() => controller.open(locale)} choose={() => void controller.choose(locale)}
816
+ reload={() => controller.reload()}
788
817
  switchBranch={name => controller.changeBranch({kind: 'switchBranch', name})}
789
818
  createBranch={(name, checkout) => controller.changeBranch({kind: 'createBranch', name, checkout})}
790
819
  createFromRemote={name => controller.changeBranch({kind: 'createBranch', name, fromRemote: true, checkout: true})}/>;
@@ -799,3 +828,112 @@ export function renderBranchButton(createElement: (component: any, props: any, .
799
828
  {vm, locale = 'en'}: {vm: object; locale?: string}) {
800
829
  return createElement(BranchEntry, {vm, locale});
801
830
  }
831
+
832
+ type History = {commits: HistoryCommit[]; cursor: HistoryCursor | null; loading: boolean; failed: boolean};
833
+
834
+ /** The history of the panel's repository, read again whenever its branches move. */
835
+ function HistoryEntry({vm, locale}: {vm: object; locale: string}) {
836
+ const {controller, state} = useController(vm);
837
+ const labels = labelsFor(locale);
838
+ const repository = state && controller?.repository || null;
839
+ const [history, setHistory] = useState<History>({commits: [], cursor: null, loading: false, failed: false});
840
+ const [selected, setSelected] = useState<string | null>(null);
841
+ const ticket = useRef(0);
842
+ const root = useRef<HTMLDivElement>(null);
843
+ const refs = state ? [state.head, ...state.branches, '', ...state.remoteBranches].join('\n') : '';
844
+ useEffect(() => {
845
+ const current = ++ticket.current;
846
+ if (!repository) {
847
+ setHistory({commits: [], cursor: null, loading: false, failed: false});
848
+ return;
849
+ }
850
+ // The rows read before stay until the new page arrives, so focus in the list is kept.
851
+ setHistory(previous => ({...previous, cursor: null, loading: true, failed: false}));
852
+ repository.history().then(page => {
853
+ if (ticket.current === current) setHistory({commits: page.commits, cursor: page.cursor, loading: false, failed: false});
854
+ }, () => {
855
+ if (ticket.current === current) setHistory({commits: [], cursor: null, loading: false, failed: true});
856
+ });
857
+ }, [repository, refs]);
858
+ const showMore = () => {
859
+ if (!repository || !history.cursor || history.loading) return;
860
+ const current = ticket.current;
861
+ setHistory(previous => ({...previous, loading: true}));
862
+ repository.history({after: history.cursor}).then(page => {
863
+ if (ticket.current === current) setHistory(previous => ({commits: [...previous.commits, ...page.commits],
864
+ cursor: page.cursor, loading: false, failed: false}));
865
+ }, () => {
866
+ if (ticket.current === current) setHistory(previous => ({...previous, cursor: null, loading: false, failed: true}));
867
+ });
868
+ };
869
+ const {report, element: snackbar} = useBranchSnackbar(locale, !!state?.project, {
870
+ open: () => controller?.open(locale), reload: () => controller?.reload()
871
+ }, () => root.current?.querySelector<HTMLElement>('[tabindex="0"]')?.focus({preventScroll: true}));
872
+ if (!controller || !state) return null;
873
+ if (!repository) return <div className="twgit-history-view" ref={root}>
874
+ <p>{labels.historyViewEmpty}</p>
875
+ <button type="button" onClick={() => void controller.choose(locale)}>{labels.choose}</button>
876
+ </div>;
877
+ const change = async (operation: BranchOperation) => {
878
+ const result = await controller.changeBranch(operation);
879
+ report(result, operation.kind === 'switchBranch' || !!operation.checkout);
880
+ return result;
881
+ };
882
+ return <div className="twgit-history-view" ref={root}>
883
+ <HistoryGraph commits={history.commits} selected={selected} locale={locale} busy={state.busy} onSelect={setSelected}
884
+ hasMore={!!history.cursor} loadingMore={history.loading} onShowMore={showMore} localBranches={state.branches}
885
+ {...state.writable ? {
886
+ // A refused create stays in the graph's form with its reason; only a done one is told in the snackbar.
887
+ onCreateBranch: async (oid: string, name: string, checkout: boolean) => {
888
+ const result = await controller.changeBranch({kind: 'createBranch', name, at: oid, checkout});
889
+ if (result.done) report(result, checkout);
890
+ return result;
891
+ },
892
+ onSwitchBranch: (name: string) => change({kind: 'switchBranch', name}),
893
+ onCreateFromRemote: (name: string) => change({kind: 'createBranch', name, fromRemote: true, checkout: true})
894
+ } : {}}/>
895
+ {history.loading && !history.commits.length && <p role="status">{labels.historyLoading}</p>}
896
+ {history.failed && <p>{labels.historyFailed}</p>}
897
+ {snackbar}
898
+ </div>;
899
+ }
900
+
901
+ /**
902
+ * Renders the history graph of the repository chosen in the panel mounted for `vm`, for a side panel of the host. It
903
+ * follows branch changes made anywhere, offers the graph's branch actions when the panel allows writes and tells their
904
+ * results in the branch button's snackbar. Before a repository is chosen it offers to choose one; it renders nothing
905
+ * while no panel is mounted for `vm`.
906
+ */
907
+ export function renderHistoryView(createElement: (component: any, props: any, ...children: any[]) => any,
908
+ {vm, locale = 'en'}: {vm: object; locale?: string}) {
909
+ return createElement(HistoryEntry, {vm, locale});
910
+ }
911
+
912
+ /**
913
+ * Follows the Git status of the files under `directory`, as the panel mounted for `vm` last read it, and calls
914
+ * `listener` with their marks by path relative to `directory`. The marks are empty while no repository is chosen or
915
+ * `directory` is outside it. Returns the function that stops following.
916
+ */
917
+ export function followChangeMarks(vm: object, directory: FileSystemDirectoryHandle,
918
+ listener: (marks: ReadonlyMap<string, ChangeMark>) => void) {
919
+ let stopped = false;
920
+ let read: Snapshot | null | undefined;
921
+ let unfollow = () => {};
922
+ const update = async (controller: Controller | undefined) => {
923
+ const {repository, snapshot} = controller ?? {repository: null, snapshot: null};
924
+ if (snapshot === read) return;
925
+ read = snapshot;
926
+ const path = repository && snapshot ? await repository.root.resolve(directory).catch(() => null) : null;
927
+ if (!stopped && read === snapshot) listener(path ? changeMarks(snapshot!.changes, path.join('/')) : new Map());
928
+ };
929
+ const unregister = findController(vm, controller => {
930
+ unfollow();
931
+ unfollow = controller ? controller.follow(() => void update(controller)) : () => {};
932
+ void update(controller);
933
+ });
934
+ return () => {
935
+ stopped = true;
936
+ unregister();
937
+ unfollow();
938
+ };
939
+ }
package/src/messages.ts CHANGED
@@ -55,7 +55,8 @@ export const messages = {
55
55
  historyActions: 'Actions', historyCreateHere: 'Create branch here…', historySwitch: 'Switch to {name}',
56
56
  historyCreateFromRemote: 'Create and switch to {name}', historyCopy: 'Copy commit ID', historyCopied: 'Copied the commit ID.',
57
57
  historyCopyFailed: 'Could not copy the commit ID.', historyRefs: 'Branches', cancel: 'Cancel',
58
- branchMenu: 'Branches', openPanel: 'Open Local Git', newBranchItem: 'New Branch…', newBranchTitle: 'New Branch'
58
+ branchMenu: 'Branches', openPanel: 'Open Local Git', newBranchItem: 'New Branch…', newBranchTitle: 'New Branch',
59
+ switchedShort: 'Switched branches.', historyViewEmpty: 'Choose a Git repository to show its history here.'
59
60
  },
60
61
  ja: {
61
62
  pullRequests: 'プルリクエスト', localChanges: 'ローカルの変更',
@@ -113,8 +114,81 @@ export const messages = {
113
114
  historyActions: '操作', historyCreateHere: 'ここからブランチを作る…', historySwitch: '{name} に切り替える',
114
115
  historyCreateFromRemote: '{name} を作って切り替える', historyCopy: 'コミットの ID をコピー', historyCopied: 'コミットの ID をコピーしました。',
115
116
  historyCopyFailed: 'コミットの ID をコピーできませんでした。', historyRefs: 'ブランチ', cancel: 'キャンセル',
116
- branchMenu: 'ブランチ', openPanel: 'ローカル Git を開く', newBranchItem: '新しいブランチ…', newBranchTitle: '新しいブランチ'
117
+ branchMenu: 'ブランチ', openPanel: 'ローカル Git を開く', newBranchItem: '新しいブランチ…', newBranchTitle: '新しいブランチ',
118
+ switchedShort: 'ブランチを切り替えました。', historyViewEmpty: 'Git リポジトリを選ぶと、ここに履歴を表示します。'
117
119
  }
118
120
  };
119
121
  export type Labels = typeof messages.en;
120
122
  export const labelsFor = (locale: string): Labels => locale.startsWith('ja') ? messages.ja : messages.en;
123
+
124
+ /** The words of sending a pull request review through the GitHub connection. */
125
+ export const reviewMessages = {
126
+ en: {
127
+ composer: 'Send a review', overall: 'Overall comment', event: 'Result',
128
+ COMMENT: 'Comment', APPROVE: 'Approve', REQUEST_CHANGES: 'Request changes',
129
+ scriptComment: 'Comment on this code', commented: 'Commented',
130
+ summaryThreads: 'Code comments sent as threads on their project.source.json: {threads}. Code comments in the review body: {body}.',
131
+ summaryBody: 'This connection sends only the review body: {body} code comments go in it.',
132
+ summaryNone: 'No code comments yet. Write them under each code change.',
133
+ send: 'Send Review', sending: 'Sending the review…', checking: 'Checking GitHub…',
134
+ sendAgain: 'Send Again', check: 'Check Whether It Was Sent', discard: 'Discard the Pending Review',
135
+ forget: 'Stop Tracking This Submission', useVersion: 'Review This Version',
136
+ sent: 'Sent the review.', openReview: 'Open the review on GitHub', openPullRequest: 'Open the pull request on GitHub',
137
+ dismiss: 'Dismiss', discarded: 'Discarded the pending review. Your words are kept.',
138
+ notStored: 'Browser storage is unavailable, so this draft is kept only while the panel is open.',
139
+ headChanged: 'The pull request changed after you started this review. The review is sent for the version shown.',
140
+ unsupported: 'This GitHub connection cannot send reviews.',
141
+ needsGrant: 'To send a review, disconnect and connect again with “Allow sending reviews”.',
142
+ allowReview: 'Allow sending reviews',
143
+ PENDING_REVIEW_EXISTS: 'You already have a pending review on this pull request on GitHub. Submit or discard it on GitHub, then send again.',
144
+ PENDING_REVIEW_LEFT: 'A pending review remains on GitHub. Only you can see it. Discard it here, or on GitHub with “Finish your review”.',
145
+ REVIEW_OUTCOME_UNKNOWN: 'It is unknown whether GitHub received the review. Check before sending again.',
146
+ pending: 'The review waits on GitHub with its code comments but is not published. Change it and send again, or discard it.',
147
+ REVIEW_REJECTED: 'GitHub refused the review, for example an approval of your own pull request.',
148
+ REVIEW_THREAD_REJECTED: 'GitHub refused a comment on a project file, so nothing was published. Comments on that file now go in the review body. Send again.',
149
+ RATE_LIMITED: 'The GitHub rate limit was reached. Try again in {seconds} seconds.',
150
+ EMPTY_REVIEW: 'Write an overall comment (needed to request changes) or a comment on the code.',
151
+ TOO_LONG: 'The review is longer than GitHub accepts (65,536 characters).',
152
+ closed: 'GitHub has no such review: it was not published, or it was discarded. You can send it again.',
153
+ lost: 'The connection no longer knows this submission. Check on GitHub whether the review was published ({count} of your reviews on this version found).',
154
+ session: 'Connect to GitHub again to send the review.',
155
+ FORBIDDEN: 'The GitHub connection is not allowed to send reviews to this repository.',
156
+ GITHUB_API_UNAVAILABLE: 'The connection cannot add code comments as threads. They now go in the review body: check the summary and send again.',
157
+ failed: 'Could not send the review. Nothing was published. Try again.'
158
+ },
159
+ ja: {
160
+ composer: 'レビューを送る', overall: '全体のコメント', event: '結果',
161
+ COMMENT: 'コメント', APPROVE: '承認', REQUEST_CHANGES: '修正を依頼',
162
+ scriptComment: 'このコードへのコメント', commented: 'コメントあり',
163
+ summaryThreads: 'コードへのコメントのうち {threads} 件をその project.source.json へのスレッドとして、{body} 件をレビュー本文に入れて送ります。',
164
+ summaryBody: 'この接続ではレビュー本文だけを送れます。コードへのコメント {body} 件を本文に入れます。',
165
+ summaryNone: 'コードへのコメントはまだありません。各コードの変更の下に書けます。',
166
+ send: 'レビューを送信', sending: 'レビューを送信中…', checking: 'GitHub で確かめています…',
167
+ sendAgain: 'もう一度送る', check: '送信されたか確かめる', discard: '保留中のレビューを破棄',
168
+ forget: 'この送信の記録を消す', useVersion: 'この版をレビューする',
169
+ sent: 'レビューを送りました。', openReview: 'GitHub でレビューを開く', openPullRequest: 'GitHub で PR を開く',
170
+ dismiss: '閉じる', discarded: '保留中のレビューを破棄しました。書いた内容は残っています。',
171
+ notStored: 'ブラウザーのストレージを使えないため、この下書きはパネルを開いている間だけ残ります。',
172
+ headChanged: 'レビューを書き始めた後に PR が更新されました。レビューは表示中の版に対して送ります。',
173
+ unsupported: 'この GitHub 接続ではレビューを送れません。',
174
+ needsGrant: 'レビューを送るには、切断して「レビューの送信を許可する」を付けて接続し直してください。',
175
+ allowReview: 'レビューの送信を許可する',
176
+ PENDING_REVIEW_EXISTS: 'この PR に、GitHub で書きかけの保留中のレビューがあります。GitHub で送るか破棄してから、もう一度送ってください。',
177
+ PENDING_REVIEW_LEFT: 'GitHub に保留中のレビューが残っています。あなたにしか見えません。ここで破棄するか、GitHub の「Finish your review」から破棄してください。',
178
+ REVIEW_OUTCOME_UNKNOWN: 'GitHub がレビューを受け取ったかわかりません。もう一度送る前に確かめてください。',
179
+ pending: 'レビューはコードへのコメント付きで GitHub に保留中ですが、公開されていません。直してもう一度送るか、破棄してください。',
180
+ REVIEW_REJECTED: 'GitHub がレビューを拒みました(自分の PR の承認など)。',
181
+ REVIEW_THREAD_REJECTED: 'GitHub が作品ファイルへのコメントを拒んだため、何も公開していません。そのファイルへのコメントはレビュー本文に入れます。もう一度送ってください。',
182
+ RATE_LIMITED: 'GitHub の利用上限に達しました。{seconds} 秒後に再試行してください。',
183
+ EMPTY_REVIEW: '全体のコメント(修正の依頼では必須)か、コードへのコメントを書いてください。',
184
+ TOO_LONG: 'レビューが GitHub の上限(65,536 文字)を超えています。',
185
+ closed: 'GitHub にそのレビューはありません。公開されなかったか、破棄されています。もう一度送れます。',
186
+ lost: '接続がこの送信の記録を持っていません。レビューが公開されたかを GitHub で確かめてください(この版へのあなたのレビュー {count} 件)。',
187
+ session: 'レビューを送るには、GitHub にもう一度接続してください。',
188
+ FORBIDDEN: 'GitHub 接続に、このリポジトリへレビューを送る権限がありません。',
189
+ GITHUB_API_UNAVAILABLE: 'この接続ではコードへのコメントをスレッドにできません。コメントはレビュー本文に入れます。件数を確かめて、もう一度送ってください。',
190
+ failed: 'レビューを送れませんでした。何も公開していません。再試行してください。'
191
+ }
192
+ };
193
+ export type ReviewLabels = typeof reviewMessages.en;
194
+ export const reviewLabelsFor = (locale: string): ReviewLabels => locale.startsWith('ja') ? reviewMessages.ja : reviewMessages.en;