@kubohiroya/turbowarp-plus-editor-git 0.0.0-stage → 0.8.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.
- package/LICENSE +373 -0
- package/README.ja.md +121 -0
- package/README.md +43 -2
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/dist/THIRD_PARTY_NOTICES.md +1186 -0
- package/dist/block-diff-location.d.ts +22 -0
- package/dist/block-diff-preview.d.ts +33 -0
- package/dist/browser.d.ts +4 -0
- package/dist/connection-settings.d.ts +35 -0
- package/dist/file-diff-view.d.ts +18 -0
- package/dist/filesystem.d.ts +89 -0
- package/dist/github-pull-requests.d.ts +38 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +3563 -0
- package/dist/messages.d.ts +112 -0
- package/dist/mutation-fs.d.ts +58 -0
- package/dist/objectSpread2-Dkd68nJH.js +101 -0
- package/dist/project-asset-paths-KV715791.js +761 -0
- package/dist/project-asset-paths.d.ts +12 -0
- package/dist/project-assets.d.ts +19 -0
- package/dist/project-source-diff.d.ts +26 -0
- package/dist/project-source-diff.js +172 -0
- package/dist/project-source-patch.d.ts +39 -0
- package/dist/project-source-review-model.d.ts +23 -0
- package/dist/project-source-review.d.ts +38 -0
- package/dist/pull-request-panel.d.ts +8 -0
- package/dist/remote-operations.d.ts +5 -0
- package/dist/repository-BQkADJmK.js +17280 -0
- package/dist/repository.d.ts +71 -0
- package/dist/repository.js +1 -0
- package/dist/scratch-block-diff-renderer-xS8OYsRl.js +1830 -0
- package/dist/scratch-block-diff-renderer.d.ts +128 -0
- package/dist/smart-http-BmHEkE7K.js +303 -0
- package/dist/smart-http.d.ts +60 -0
- package/dist/smart-http.js +1 -0
- package/dist/style.css +138 -0
- package/package.json +65 -4
- package/src/block-diff-location.ts +63 -0
- package/src/block-diff-preview.tsx +117 -0
- package/src/browser.ts +5 -0
- package/src/connection-settings.tsx +103 -0
- package/src/file-diff-view.tsx +44 -0
- package/src/filesystem.ts +125 -0
- package/src/github-pull-requests.ts +229 -0
- package/src/index.tsx +352 -0
- package/src/messages.ts +64 -0
- package/src/mutation-fs.ts +204 -0
- package/src/project-asset-paths.ts +33 -0
- package/src/project-assets.ts +66 -0
- package/src/project-source-diff.css +108 -0
- package/src/project-source-diff.tsx +139 -0
- package/src/project-source-patch.ts +126 -0
- package/src/project-source-review-model.ts +116 -0
- package/src/project-source-review.tsx +250 -0
- package/src/pull-request-panel.tsx +134 -0
- package/src/remote-operations.ts +90 -0
- package/src/repository.ts +364 -0
- package/src/scratch-block-diff-renderer.ts +331 -0
- package/src/smart-http.ts +190 -0
- package/src/style.css +29 -0
- package/src/styles.d.ts +1 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +26 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {applyPatch, createTwoFilesPatch, parsePatch} from 'diff';
|
|
2
|
+
|
|
3
|
+
export type BlockTable = Record<string, unknown>;
|
|
4
|
+
export interface ProjectTarget {
|
|
5
|
+
name: string;
|
|
6
|
+
isStage: boolean;
|
|
7
|
+
blocks: BlockTable;
|
|
8
|
+
}
|
|
9
|
+
export type ProjectSource = Record<string, unknown> & {targets: ProjectTarget[]};
|
|
10
|
+
|
|
11
|
+
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
12
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
|
|
14
|
+
function readProject(source: string): ProjectSource {
|
|
15
|
+
const project: unknown = JSON.parse(source);
|
|
16
|
+
if (!isRecord(project) || !Array.isArray(project.targets) || !project.targets.every(target =>
|
|
17
|
+
isRecord(target) && typeof target.name === 'string' && typeof target.isStage === 'boolean' && (target.blocks === undefined || isRecord(target.blocks)))) {
|
|
18
|
+
throw new Error('Expected a project.source.json with Scratch targets and blocks.');
|
|
19
|
+
}
|
|
20
|
+
const parsed = project as ProjectSource;
|
|
21
|
+
return {...parsed, targets: parsed.targets.map(target => ({...target, blocks: target.blocks ?? {}}))};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Reads one version of a project.source.json. `null` stands for a version in which the file does not exist. */
|
|
25
|
+
export function parseProjectSource(source: string | null): ProjectSource {
|
|
26
|
+
return source === null ? {targets: []} : readProject(source);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Keep the original lines and include enough context to render both sides from the patch alone. */
|
|
30
|
+
export function createProjectSourceDiff(before: string, after: string): string {
|
|
31
|
+
readProject(before);
|
|
32
|
+
readProject(after);
|
|
33
|
+
const patch = createTwoFilesPatch('a/project.source.json', 'b/project.source.json', before, after,
|
|
34
|
+
undefined, undefined, {context: Infinity, timeout: 1000});
|
|
35
|
+
if (patch === undefined) throw new Error('Project source diff exceeded its time limit.');
|
|
36
|
+
return 'diff --git a/project.source.json b/project.source.json\n' + patch.replace(/^=+\n/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readProjectSourceDiff(patch: string, beforeSource?: string): [ProjectSource, ProjectSource] | null {
|
|
40
|
+
const [file, extra] = parsePatch(patch);
|
|
41
|
+
if (!file || extra || !file.oldFileName || !file.newFileName) throw new Error('Expected a single unified text diff.');
|
|
42
|
+
if (beforeSource !== undefined) {
|
|
43
|
+
const before = readProject(beforeSource);
|
|
44
|
+
const after = applyPatch(beforeSource, file);
|
|
45
|
+
if (after === false) throw new Error('The original project does not match this diff.');
|
|
46
|
+
return [before, readProject(after)];
|
|
47
|
+
}
|
|
48
|
+
if (!file.hunks.length) return null;
|
|
49
|
+
const before: string[] = [], after: string[] = [];
|
|
50
|
+
for (const hunk of file.hunks) {
|
|
51
|
+
if (hunk.oldStart !== before.length + 1 || hunk.newStart !== after.length + 1) {
|
|
52
|
+
throw new Error('The diff omits project context.');
|
|
53
|
+
}
|
|
54
|
+
for (const line of hunk.lines) {
|
|
55
|
+
if (line.startsWith(' ') || line.startsWith('-')) before.push(line.slice(1));
|
|
56
|
+
if (line.startsWith(' ') || line.startsWith('+')) after.push(line.slice(1));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [readProject(before.join('\n')), readProject(after.join('\n'))];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function equal(a: unknown, b: unknown): boolean {
|
|
63
|
+
if (a === b) return true;
|
|
64
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((value, i) => equal(value, b[i]));
|
|
65
|
+
if (!isRecord(a) || !isRecord(b)) return false;
|
|
66
|
+
const keys = Object.keys(a);
|
|
67
|
+
return keys.length === Object.keys(b).length && keys.every(key => Object.hasOwn(b, key) && equal(a[key], b[key]));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const targetKey = (target: ProjectTarget) => target.isStage ? 'stage' : 'sprite:' + target.name;
|
|
71
|
+
|
|
72
|
+
export interface BlockSide { target: ProjectTarget; id: string }
|
|
73
|
+
export interface BlockPart { kind: 'input' | 'field'; name: string }
|
|
74
|
+
|
|
75
|
+
function changedParts(before: unknown, after: unknown): BlockPart[] {
|
|
76
|
+
if (!isRecord(before) || !isRecord(after)) return [];
|
|
77
|
+
const result: BlockPart[] = [];
|
|
78
|
+
// A shadow hidden behind a reporter is not the field shown in the workspace.
|
|
79
|
+
const visibleInput = (input: unknown) => Array.isArray(input) ? input[1] ?? input[2] : input;
|
|
80
|
+
for (const group of ['inputs', 'fields'] as const) {
|
|
81
|
+
const oldGroup = isRecord(before[group]) ? before[group] : {};
|
|
82
|
+
const newGroup = isRecord(after[group]) ? after[group] : {};
|
|
83
|
+
for (const name of new Set([...Object.keys(oldGroup), ...Object.keys(newGroup)])) {
|
|
84
|
+
const a = oldGroup[name], b = newGroup[name];
|
|
85
|
+
if (group === 'inputs' ? !equal(visibleInput(a), visibleInput(b)) : !equal(a, b)) {
|
|
86
|
+
result.push({kind: group === 'inputs' ? 'input' : 'field', name});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
export type BlockChangeKind = 'added' | 'removed' | 'updated';
|
|
93
|
+
export interface BlockChange {
|
|
94
|
+
key: string;
|
|
95
|
+
before?: BlockSide;
|
|
96
|
+
after?: BlockSide;
|
|
97
|
+
kind: BlockChangeKind;
|
|
98
|
+
fields: string[];
|
|
99
|
+
parts: BlockPart[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function compareProjectBlocks(before: ProjectSource, after: ProjectSource) {
|
|
103
|
+
const oldTargets = new Map(before.targets.map(target => [targetKey(target), target]));
|
|
104
|
+
const newTargets = new Map(after.targets.map(target => [targetKey(target), target]));
|
|
105
|
+
const changes: BlockChange[] = [];
|
|
106
|
+
let unchanged = 0;
|
|
107
|
+
for (const key of new Set([...oldTargets.keys(), ...newTargets.keys()])) {
|
|
108
|
+
const oldTarget = oldTargets.get(key), newTarget = newTargets.get(key);
|
|
109
|
+
const oldBlocks: BlockTable = oldTarget?.blocks ?? {}, newBlocks: BlockTable = newTarget?.blocks ?? {};
|
|
110
|
+
for (const id of new Set([...Object.keys(oldBlocks), ...Object.keys(newBlocks)])) {
|
|
111
|
+
const hadBlock = Object.hasOwn(oldBlocks, id), hasBlock = Object.hasOwn(newBlocks, id);
|
|
112
|
+
const a = oldBlocks[id], b = newBlocks[id];
|
|
113
|
+
if (hadBlock && hasBlock && equal(a, b)) {
|
|
114
|
+
unchanged++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const fields = isRecord(a) && isRecord(b) ?
|
|
118
|
+
[...new Set([...Object.keys(a), ...Object.keys(b)])].filter(field => !equal(a[field], b[field])) : [];
|
|
119
|
+
changes.push({key: JSON.stringify([key, id]), before: hadBlock && oldTarget ? {target: oldTarget, id} : undefined,
|
|
120
|
+
after: hasBlock && newTarget ? {target: newTarget, id} : undefined,
|
|
121
|
+
kind: !hadBlock ? 'added' : !hasBlock ? 'removed' : 'updated', fields, parts: changedParts(a, b)});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const withoutBlocks = (project: ProjectSource) => ({...project, targets: project.targets.map(({blocks: _, ...target}) => target)});
|
|
125
|
+
return {changes, unchanged, otherChanges: !equal(withoutBlocks(before), withoutBlocks(after))};
|
|
126
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {compareProjectBlocks, equal, isRecord, targetKey} from './project-source-patch.js';
|
|
2
|
+
import type {BlockChange, BlockSide, ProjectSource, ProjectTarget} from './project-source-patch.js';
|
|
3
|
+
import type {ScriptBlockMark} from './scratch-block-diff-renderer.js';
|
|
4
|
+
|
|
5
|
+
export interface ScriptReviewSide extends BlockSide { marks: ScriptBlockMark[] }
|
|
6
|
+
export interface ScriptReview {
|
|
7
|
+
id: string;
|
|
8
|
+
before: ScriptReviewSide[];
|
|
9
|
+
after: ScriptReviewSide[];
|
|
10
|
+
}
|
|
11
|
+
export interface TargetReview {
|
|
12
|
+
name: string;
|
|
13
|
+
isStage: boolean;
|
|
14
|
+
/** The stage or sprite in each version, absent where it does not exist. */
|
|
15
|
+
before?: ProjectTarget;
|
|
16
|
+
after?: ProjectTarget;
|
|
17
|
+
scripts: ScriptReview[];
|
|
18
|
+
properties: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function scriptRoots(target?: ProjectTarget) {
|
|
22
|
+
const roots = new Map<string, string>();
|
|
23
|
+
if (!target) return roots;
|
|
24
|
+
for (const id of Object.keys(target.blocks)) {
|
|
25
|
+
if (roots.has(id)) continue;
|
|
26
|
+
const chain = new Set<string>();
|
|
27
|
+
let current = id;
|
|
28
|
+
while (!roots.has(current) && !chain.has(current)) {
|
|
29
|
+
chain.add(current);
|
|
30
|
+
const block = target.blocks[current];
|
|
31
|
+
if (!isRecord(block) || typeof block.parent !== 'string' || !Object.hasOwn(target.blocks, block.parent)) break;
|
|
32
|
+
current = block.parent;
|
|
33
|
+
}
|
|
34
|
+
const root = roots.get(current) ?? current;
|
|
35
|
+
for (const block of chain) roots.set(block, root);
|
|
36
|
+
}
|
|
37
|
+
return roots;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function groupScripts(before: ProjectTarget | undefined, after: ProjectTarget | undefined, changes: readonly BlockChange[]) {
|
|
41
|
+
const roots = {before: scriptRoots(before), after: scriptRoots(after)};
|
|
42
|
+
const parents = new Map<string, string>();
|
|
43
|
+
function groupOf(key: string) {
|
|
44
|
+
let parent = key;
|
|
45
|
+
while (parents.has(parent)) parent = parents.get(parent)!;
|
|
46
|
+
for (let current = key; current !== parent;) {
|
|
47
|
+
const next = parents.get(current)!;
|
|
48
|
+
parents.set(current, parent);
|
|
49
|
+
current = next;
|
|
50
|
+
}
|
|
51
|
+
return parent;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Blocks retained across versions connect their original and new scripts, including splits and merges.
|
|
55
|
+
for (const [id, oldRoot] of roots.before) {
|
|
56
|
+
const newRoot = roots.after.get(id);
|
|
57
|
+
if (newRoot === undefined) continue;
|
|
58
|
+
const a = groupOf('before:' + oldRoot), b = groupOf('after:' + newRoot);
|
|
59
|
+
if (a !== b) parents.set(b, a);
|
|
60
|
+
}
|
|
61
|
+
const groups = new Map<string, ScriptReview>();
|
|
62
|
+
const sides = new Map<string, ScriptReviewSide>();
|
|
63
|
+
for (const version of ['before', 'after'] as const) {
|
|
64
|
+
const target = version === 'before' ? before : after;
|
|
65
|
+
if (!target) continue;
|
|
66
|
+
for (const root of new Set(roots[version].values())) {
|
|
67
|
+
const key = version + ':' + root;
|
|
68
|
+
const id = groupOf(key);
|
|
69
|
+
const group = groups.get(id) ?? {id, before: [], after: []};
|
|
70
|
+
const side: ScriptReviewSide = {target, id: root, marks: []};
|
|
71
|
+
group[version].push(side);
|
|
72
|
+
groups.set(id, group);
|
|
73
|
+
sides.set(key, side);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const affected = new Set<string>();
|
|
77
|
+
for (const change of changes) {
|
|
78
|
+
for (const version of ['before', 'after'] as const) {
|
|
79
|
+
const block = change[version];
|
|
80
|
+
if (!block) continue;
|
|
81
|
+
const key = version + ':' + roots[version].get(block.id);
|
|
82
|
+
sides.get(key)?.marks.push({id: block.id, kind: change.kind, parts: change.parts});
|
|
83
|
+
affected.add(groupOf(key));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...affected].map(id => groups.get(id)!);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function changedProperties(before: Record<string, unknown>, after: Record<string, unknown>, except: string) {
|
|
90
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(key => key !== except && !equal(before[key], after[key]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function compareProjectForReview(before: ProjectSource, after: ProjectSource) {
|
|
94
|
+
const {changes} = compareProjectBlocks(before, after);
|
|
95
|
+
const byTarget = new Map<string, BlockChange[]>();
|
|
96
|
+
for (const change of changes) {
|
|
97
|
+
const key = targetKey((change.after ?? change.before)!.target);
|
|
98
|
+
const existing = byTarget.get(key);
|
|
99
|
+
if (existing) existing.push(change);
|
|
100
|
+
else byTarget.set(key, [change]);
|
|
101
|
+
}
|
|
102
|
+
const oldTargets = new Map(before.targets.map(target => [targetKey(target), target]));
|
|
103
|
+
const newTargets = new Map(after.targets.map(target => [targetKey(target), target]));
|
|
104
|
+
const targets: TargetReview[] = [];
|
|
105
|
+
for (const key of new Set([...oldTargets.keys(), ...newTargets.keys()])) {
|
|
106
|
+
const a = oldTargets.get(key), b = newTargets.get(key);
|
|
107
|
+
const edits = byTarget.get(key);
|
|
108
|
+
const scripts = edits ? groupScripts(a, b, edits) : [];
|
|
109
|
+
const properties = changedProperties({...a}, {...b}, 'blocks');
|
|
110
|
+
if (scripts.length || properties.length) {
|
|
111
|
+
const {name, isStage} = (b ?? a)!;
|
|
112
|
+
targets.push({name, isStage, scripts, properties, before: a, after: b});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return {targets, properties: changedProperties(before, after, 'targets')};
|
|
116
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import React, {useMemo, useState} from 'react';
|
|
2
|
+
import {BlockPreview, usePreviewSync} from './block-diff-preview.js';
|
|
3
|
+
import {isRecord, parseProjectSource, readProjectSourceDiff} from './project-source-patch.js';
|
|
4
|
+
import type {BlockSide} from './project-source-patch.js';
|
|
5
|
+
import {compareProjectForReview} from './project-source-review-model.js';
|
|
6
|
+
import type {ScriptReview, TargetReview} from './project-source-review-model.js';
|
|
7
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
8
|
+
import {currentCostume, useCostumeImage} from './project-assets.js';
|
|
9
|
+
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
10
|
+
|
|
11
|
+
type Comparison = ReturnType<typeof compareProjectForReview>;
|
|
12
|
+
|
|
13
|
+
export type ProjectSourceReviewFile = {
|
|
14
|
+
path: string;
|
|
15
|
+
summary?: string;
|
|
16
|
+
previousPath?: string;
|
|
17
|
+
unavailable?: boolean;
|
|
18
|
+
} & ({patch: string; beforeSource?: string; loadAsset?: ProjectAssetLoader} | {status: 'added' | 'removed' | 'updated'});
|
|
19
|
+
|
|
20
|
+
export interface ProjectSourceReviewProps {
|
|
21
|
+
title: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
verification?: readonly string[];
|
|
24
|
+
files: readonly ProjectSourceReviewFile[];
|
|
25
|
+
renderBlock: ProjectBlockRenderer;
|
|
26
|
+
locale?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Like the Editor's Code tab, a connected group of blocks is called code, and each item is named by its hat block.
|
|
30
|
+
const ja = {
|
|
31
|
+
review: '変更レビュー', fullDescription: 'PR 本文を表示', checks: '確認してほしいこと', files: '変更ファイル', targets: '影響するステージ・スプライト',
|
|
32
|
+
scripts: 'コードの変更', scriptCount: (count: number) => `コードの変更 · ${count} か所`,
|
|
33
|
+
reviewed: '確認済み', before: '変更前', after: '変更後',
|
|
34
|
+
updated: '変更', added: '追加', removed: '削除', reorganized: '再編',
|
|
35
|
+
absent: 'この版にはこのコードがありません', loading: 'コードを描画中…',
|
|
36
|
+
unavailable: 'このコードを描画できません。',
|
|
37
|
+
related: '関連ファイル', properties: 'コード以外の変更', invalid: 'この作品の差分を読み込めません。',
|
|
38
|
+
flag: '緑の旗が押されたとき', broadcast: 'を受け取ったとき', key: 'キーが押されたとき',
|
|
39
|
+
click: 'このスプライトが押されたとき', script: 'コード', noChanges: '作品に内容の変更はありません。',
|
|
40
|
+
comparison: '作品の変更', unreadable: '作品データとして読み込めないため、テキストの差分を表示します。'
|
|
41
|
+
};
|
|
42
|
+
const en: typeof ja = {
|
|
43
|
+
review: 'Change review', fullDescription: 'Show full PR description', checks: 'Review checklist', files: 'Changed files', targets: 'Affected stages and sprites',
|
|
44
|
+
scripts: 'Code changes', scriptCount: count => `Code changes · ${count}`,
|
|
45
|
+
reviewed: 'Reviewed', before: 'Before', after: 'After',
|
|
46
|
+
updated: 'Changed', added: 'Added', removed: 'Removed', reorganized: 'Reorganized',
|
|
47
|
+
absent: 'No code in this version', loading: 'Rendering code…',
|
|
48
|
+
unavailable: 'This code could not be rendered.',
|
|
49
|
+
related: 'Related file', properties: 'Changes outside the code', invalid: 'This project diff could not be read.',
|
|
50
|
+
flag: 'When green flag clicked', broadcast: 'received', key: 'When key pressed',
|
|
51
|
+
click: 'When this sprite clicked', script: 'Code', noChanges: 'No changes to the project contents.',
|
|
52
|
+
comparison: 'Project changes', unreadable: 'This file could not be read as project data, so its text diff is shown.'
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const properties = new Map<string, readonly [string, string]>([
|
|
56
|
+
['variables', ['変数', 'Variables']], ['lists', ['リスト', 'Lists']], ['broadcasts', ['メッセージ', 'Broadcast messages']],
|
|
57
|
+
['costumes', ['コスチューム・背景', 'Costumes and backdrops']], ['sounds', ['音', 'Sounds']],
|
|
58
|
+
['comments', ['コメント', 'Comments']], ['monitors', ['モニター', 'Monitors']], ['extensions', ['拡張機能', 'Extensions']],
|
|
59
|
+
['x', ['X 座標', 'X position']], ['y', ['Y 座標', 'Y position']], ['visible', ['表示状態', 'Visibility']],
|
|
60
|
+
['direction', ['向き', 'Direction']], ['size', ['大きさ', 'Size']], ['currentCostume', ['表示するコスチューム', 'Selected costume']]
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
function scriptTitle(side: BlockSide, labels: typeof ja) {
|
|
64
|
+
const block = side.target.blocks[side.id];
|
|
65
|
+
if (!isRecord(block)) return labels.script;
|
|
66
|
+
if (block.opcode === 'event_whenflagclicked') return labels.flag;
|
|
67
|
+
if (block.opcode === 'event_whenthisspriteclicked') return labels.click;
|
|
68
|
+
if (block.opcode === 'event_whenkeypressed') return labels.key;
|
|
69
|
+
if (block.opcode === 'event_whenbroadcastreceived') {
|
|
70
|
+
const field = isRecord(block.fields) ? block.fields.BROADCAST_OPTION : undefined;
|
|
71
|
+
const message = Array.isArray(field) && typeof field[0] === 'string' ? field[0] : '';
|
|
72
|
+
if (message) return labels === ja ? `「${message}」${labels.broadcast}` : `“${message}” ${labels.broadcast}`;
|
|
73
|
+
}
|
|
74
|
+
return typeof block.opcode === 'string' ? block.opcode : labels.script;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset}: {
|
|
78
|
+
script: ScriptReview; labels: typeof ja; reviewed?: boolean; setReviewed?(value: boolean): void;
|
|
79
|
+
renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
|
|
80
|
+
}) {
|
|
81
|
+
const [open, setOpen] = useState(false);
|
|
82
|
+
const {sync, toggle} = usePreviewSync(labels === ja ? 'ja' : 'en');
|
|
83
|
+
const status = !script.before.length ? 'added' : !script.after.length ? 'removed' :
|
|
84
|
+
script.before.length !== 1 || script.after.length !== 1 ? 'reorganized' : 'updated';
|
|
85
|
+
const beforeTitle = script.before[0] && scriptTitle(script.before[0], labels);
|
|
86
|
+
const afterTitle = script.after[0] && scriptTitle(script.after[0], labels);
|
|
87
|
+
const title = beforeTitle && afterTitle && beforeTitle !== afterTitle ? beforeTitle + ' → ' + afterTitle : afterTitle ?? beforeTitle;
|
|
88
|
+
return <details className="twgit-review-script" onToggle={event => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
|
89
|
+
<summary>
|
|
90
|
+
<span className="twgit-review-status">{labels[status]}</span>
|
|
91
|
+
<strong>{title}</strong>
|
|
92
|
+
{status === 'reorganized' && <small>{script.before.length} → {script.after.length}</small>}
|
|
93
|
+
{reviewed && <small>{labels.reviewed}</small>}
|
|
94
|
+
</summary>
|
|
95
|
+
{open && <>
|
|
96
|
+
<div className="twgit-block-diff-sides">
|
|
97
|
+
{(['before', 'after'] as const).map(version => <div key={version}>
|
|
98
|
+
{script[version].length ? script[version].map((side, index, sides) => <BlockPreview key={side.id} side={side}
|
|
99
|
+
title={sides.length > 1 ? `${labels[version]} ${index + 1}` : labels[version]}
|
|
100
|
+
marks={side.marks} renderBlock={renderBlock} withContext labels={labels}
|
|
101
|
+
version={version} loadAsset={loadAsset} sync={sync}/>) :
|
|
102
|
+
<BlockPreview title={labels[version]} renderBlock={renderBlock} withContext labels={labels}/>}
|
|
103
|
+
</div>)}
|
|
104
|
+
</div>
|
|
105
|
+
{toggle}
|
|
106
|
+
{setReviewed && <label className="twgit-review-checked"><input type="checkbox" checked={reviewed}
|
|
107
|
+
onChange={event => setReviewed(event.currentTarget.checked)}/> {labels.reviewed}</label>}
|
|
108
|
+
</>}
|
|
109
|
+
</details>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function propertyNames(keys: readonly string[], labels: typeof ja) {
|
|
113
|
+
return keys.map(key => properties.get(key)?.[labels === ja ? 0 : 1] ?? key).join(' · ');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The current costume (the backdrop for the stage) of each version of a target that has one. A changed costume shows
|
|
118
|
+
* before → after. Nothing is shown until an image is read, so the heading falls back to the name alone.
|
|
119
|
+
*/
|
|
120
|
+
function TargetThumbnails({target, labels, loadAsset}: {target: TargetReview; labels: typeof ja; loadAsset?: ProjectAssetLoader}) {
|
|
121
|
+
const images = {before: useCostumeImage(target.before, 'before', loadAsset), after: useCostumeImage(target.after, 'after', loadAsset)};
|
|
122
|
+
const costumes = {before: target.before && currentCostume(target.before), after: target.after && currentCostume(target.after)};
|
|
123
|
+
const changed = !!costumes.before && !!costumes.after && costumes.before.md5ext !== costumes.after.md5ext;
|
|
124
|
+
// An unchanged costume is the same file in both versions, so either read shows it.
|
|
125
|
+
const versions: ProjectVersion[] = changed ? ['before', 'after'] : [images.after ? 'after' : 'before'];
|
|
126
|
+
const shown = versions.filter(version => images[version]);
|
|
127
|
+
if (!shown.length) return null;
|
|
128
|
+
return <span className="twgit-review-thumbnails">
|
|
129
|
+
{shown.map((version, index) => <React.Fragment key={version}>
|
|
130
|
+
{index > 0 && <span aria-hidden="true">→</span>}
|
|
131
|
+
<img src={images[version]!} title={costumes[version]?.name || undefined}
|
|
132
|
+
alt={changed ? `${labels[version]}: ${costumes[version]?.name ?? ''}` : ''}/>
|
|
133
|
+
</React.Fragment>)}
|
|
134
|
+
</span>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Lists the changed stages and sprites of one project file, each with its code comparisons. */
|
|
138
|
+
function ComparisonTargets({comparison, labels, renderBlock, loadAsset, review}: {
|
|
139
|
+
comparison: Comparison; labels: typeof ja; renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
|
|
140
|
+
review?: {file: string; keys: ReadonlySet<string>; set(key: string, value: boolean): void};
|
|
141
|
+
}) {
|
|
142
|
+
return <>
|
|
143
|
+
{comparison.properties.length > 0 && <p>{labels.properties} · {propertyNames(comparison.properties, labels)}</p>}
|
|
144
|
+
{!comparison.targets.length && !comparison.properties.length && <p>{labels.noChanges}</p>}
|
|
145
|
+
{comparison.targets.map(target => <section className="twgit-review-target" key={JSON.stringify([target.isStage, target.name])}>
|
|
146
|
+
<h5><TargetThumbnails target={target} labels={labels} loadAsset={loadAsset}/>
|
|
147
|
+
{target.name}{target.scripts.length > 0 && <small>{labels.scriptCount(target.scripts.length)}</small>}</h5>
|
|
148
|
+
{target.properties.length > 0 && <p>{labels.properties} · {propertyNames(target.properties, labels)}</p>}
|
|
149
|
+
{target.scripts.map(script => {
|
|
150
|
+
const key = JSON.stringify([review?.file, target.isStage, target.name, script.id]);
|
|
151
|
+
return <ScriptComparison key={key} script={script} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
|
|
152
|
+
reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}/>;
|
|
153
|
+
})}
|
|
154
|
+
</section>)}
|
|
155
|
+
</>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const countScripts = (comparison: Comparison) => comparison.targets.reduce((count, target) => count + target.scripts.length, 0);
|
|
159
|
+
|
|
160
|
+
export interface ProjectSourceComparisonProps {
|
|
161
|
+
/** The earlier project.source.json, or `null` when the file did not exist yet. */
|
|
162
|
+
before: string | null;
|
|
163
|
+
/** The later project.source.json, or `null` when the file was removed. */
|
|
164
|
+
after: string | null;
|
|
165
|
+
renderBlock: ProjectBlockRenderer;
|
|
166
|
+
locale?: string;
|
|
167
|
+
/** Shown after an explanation when either version cannot be read as a project. Without it, only an error is shown. */
|
|
168
|
+
fallback?: React.ReactNode;
|
|
169
|
+
/** Reads the images of both versions for thumbnails. Without it, stages and sprites are shown by name. */
|
|
170
|
+
loadAsset?: ProjectAssetLoader;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
|
|
174
|
+
export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset}: ProjectSourceComparisonProps) {
|
|
175
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
176
|
+
const comparison = useMemo(() => {
|
|
177
|
+
try {
|
|
178
|
+
return compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}, [before, after]);
|
|
183
|
+
if (!comparison) return fallback === undefined ? <p role="alert">{labels.invalid}</p> :
|
|
184
|
+
<><p role="alert" className="twgit-review-unreadable">{labels.unreadable}</p>{fallback}</>;
|
|
185
|
+
return <section className="twgit-block-diff twgit-review twgit-review-comparison" aria-label={labels.comparison}>
|
|
186
|
+
<header className="twgit-review-overview">
|
|
187
|
+
<dl>
|
|
188
|
+
<div><dt>{labels.targets}</dt><dd>{comparison.targets.length}</dd></div>
|
|
189
|
+
<div><dt>{labels.scripts}</dt><dd>{countScripts(comparison)}</dd></div>
|
|
190
|
+
</dl>
|
|
191
|
+
</header>
|
|
192
|
+
<ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}/>
|
|
193
|
+
</section>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function ProjectSourceReview({title, description, verification, files, renderBlock, locale = 'en'}: ProjectSourceReviewProps) {
|
|
197
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
198
|
+
const entries = useMemo(() => files.map(file => {
|
|
199
|
+
if (!('patch' in file)) return {file};
|
|
200
|
+
try {
|
|
201
|
+
const projects = readProjectSourceDiff(file.patch, file.beforeSource);
|
|
202
|
+
return {file, comparison: projects ? compareProjectForReview(...projects) : {targets: [], properties: []}};
|
|
203
|
+
} catch {
|
|
204
|
+
return {file, invalid: true};
|
|
205
|
+
}
|
|
206
|
+
}), [files]);
|
|
207
|
+
const [checked, setChecked] = useState<{entries: typeof entries; keys: Set<string>}>();
|
|
208
|
+
const reviewed = checked?.entries === entries ? checked.keys : new Set<string>();
|
|
209
|
+
const setReviewed = (key: string, value: boolean) => setChecked(previous => {
|
|
210
|
+
const keys = new Set(previous?.entries === entries ? previous.keys : []);
|
|
211
|
+
if (value) keys.add(key);
|
|
212
|
+
else keys.delete(key);
|
|
213
|
+
return {entries, keys};
|
|
214
|
+
});
|
|
215
|
+
const targets = entries.reduce((total, entry) => total + (entry.comparison?.targets.length ?? 0), 0);
|
|
216
|
+
const scripts = entries.reduce((total, entry) => total + (entry.comparison ? countScripts(entry.comparison) : 0), 0);
|
|
217
|
+
const body = description?.trim();
|
|
218
|
+
const longBody = !!body && body.length > 420;
|
|
219
|
+
const excerpt = longBody ? body.slice(0, 250).trimEnd() + '…' : body;
|
|
220
|
+
return <section className="twgit-block-diff twgit-review" aria-label={labels.review}>
|
|
221
|
+
<header className="twgit-review-overview">
|
|
222
|
+
<p>{labels.review}</p>
|
|
223
|
+
<h2>{title}</h2>
|
|
224
|
+
{excerpt && <p className="twgit-review-description">{excerpt}</p>}
|
|
225
|
+
<dl>
|
|
226
|
+
<div><dt>{labels.files}</dt><dd>{entries.length}</dd></div>
|
|
227
|
+
<div><dt>{labels.targets}</dt><dd>{targets}</dd></div>
|
|
228
|
+
<div><dt>{labels.scripts}</dt><dd>{scripts}</dd></div>
|
|
229
|
+
{scripts > 0 && <div><dt>{labels.reviewed}</dt><dd>{reviewed.size} / {scripts}</dd></div>}
|
|
230
|
+
</dl>
|
|
231
|
+
{longBody && <details className="twgit-review-body">
|
|
232
|
+
<summary>{labels.fullDescription}</summary>
|
|
233
|
+
<p className="twgit-review-description">{body}</p>
|
|
234
|
+
</details>}
|
|
235
|
+
{!!verification?.length && <div className="twgit-review-checklist">
|
|
236
|
+
<h3>{labels.checks}</h3>
|
|
237
|
+
<ul>{verification.map((item, index) => <li key={index}>{item}</li>)}</ul>
|
|
238
|
+
</div>}
|
|
239
|
+
</header>
|
|
240
|
+
<h3 className="twgit-review-files-title">{labels.files}</h3>
|
|
241
|
+
{entries.map(({file, comparison, invalid}) => <section key={file.path} className="twgit-review-file">
|
|
242
|
+
<header><h4>{file.previousPath ? file.previousPath + ' → ' + file.path : file.path}</h4>{file.summary && <p>{file.summary}</p>}</header>
|
|
243
|
+
{'status' in file && <p>{labels.related} · {labels[file.status]}</p>}
|
|
244
|
+
{(invalid || file.unavailable) && <p role="alert">{labels.invalid}</p>}
|
|
245
|
+
{comparison && <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}
|
|
246
|
+
loadAsset={'loadAsset' in file ? file.loadAsset : undefined}
|
|
247
|
+
review={{file: file.path, keys: reviewed, set: setReviewed}}/>}
|
|
248
|
+
</section>)}
|
|
249
|
+
</section>;
|
|
250
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, {useEffect, useMemo, useState} from 'react';
|
|
2
|
+
import {createGitHubPullRequests, gitHubRepositoryFromOrigin, PullRequestError} from './github-pull-requests.js';
|
|
3
|
+
import type {GitHubPullRequests} from './github-pull-requests.js';
|
|
4
|
+
import {ProjectSourceReview} from './project-source-review.js';
|
|
5
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
6
|
+
import type {Repository} from './repository.js';
|
|
7
|
+
|
|
8
|
+
const en = {
|
|
9
|
+
title: 'Pull requests', number: 'Pull request number', openNumber: 'Open pull request',
|
|
10
|
+
open: 'Open', closed: 'Closed', merged: 'Merged', draft: 'Draft',
|
|
11
|
+
prev: 'Previous page', next: 'Next page', empty: 'No pull requests on this page.',
|
|
12
|
+
loading: 'Reading GitHub…', select: 'Select a pull request to review its changes.', onGitHub: 'Open on GitHub',
|
|
13
|
+
noOrigin: 'Choose a repository whose origin is on GitHub.',
|
|
14
|
+
unavailable: 'GitHub could not read this repository or pull request. The panel supports public repositories.',
|
|
15
|
+
rateLimit: 'The public GitHub API rate limit was reached. Try again after the limit resets.',
|
|
16
|
+
failed: 'Could not read GitHub. Try again.', changed: 'The pull request changed while it was being read. Open it again.',
|
|
17
|
+
tooManyFiles: 'GitHub only returns the first 3,000 changed files. This pull request exceeds that limit.'
|
|
18
|
+
};
|
|
19
|
+
const ja: typeof en = {
|
|
20
|
+
title: 'プルリクエスト', number: 'プルリクエスト番号', openNumber: 'PR を開く',
|
|
21
|
+
open: 'オープン', closed: 'クローズ済み', merged: 'マージ済み', draft: 'ドラフト',
|
|
22
|
+
prev: '前のページ', next: '次のページ', empty: 'このページにプルリクエストはありません。',
|
|
23
|
+
loading: 'GitHub から取得中…', select: 'プルリクエストを選ぶと差分内容を確認できます。', onGitHub: 'GitHub で開く',
|
|
24
|
+
noOrigin: 'GitHub のリポジトリを origin に設定した作業フォルダを選んでください。',
|
|
25
|
+
unavailable: 'GitHub のリポジトリまたは PR を取得できません。公開リポジトリに対応しています。',
|
|
26
|
+
rateLimit: '公開 GitHub API の利用上限に達しました。上限の解除後に再試行してください。',
|
|
27
|
+
failed: 'GitHub から取得できませんでした。再試行してください。',
|
|
28
|
+
changed: '取得中にプルリクエストが更新されました。もう一度開いてください。',
|
|
29
|
+
tooManyFiles: '変更ファイルが GitHub API の取得上限である 3,000 件を超えています。'
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function errorMessage(error: unknown, labels: typeof en) {
|
|
33
|
+
return error instanceof PullRequestError ? labels[error.code] : labels.failed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Result<T> = {data: T; error?: never} | {data?: never; error: string};
|
|
37
|
+
type Listing = Awaited<ReturnType<GitHubPullRequests['list']>>;
|
|
38
|
+
type Pull = Awaited<ReturnType<GitHubPullRequests['load']>>;
|
|
39
|
+
|
|
40
|
+
function PullRequests({name, labels, locale, renderBlock}: {
|
|
41
|
+
name: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
|
|
42
|
+
}) {
|
|
43
|
+
const client = useMemo(() => createGitHubPullRequests(name), [name]);
|
|
44
|
+
const [page, setPage] = useState(1);
|
|
45
|
+
const [listing, setListing] = useState<{page: number; result: Result<Listing>}>();
|
|
46
|
+
const [input, setInput] = useState('');
|
|
47
|
+
const [selection, setSelection] = useState<{number: number}>();
|
|
48
|
+
const [loaded, setLoaded] = useState<{selection: NonNullable<typeof selection>; result: Result<Pull>}>();
|
|
49
|
+
const list = listing?.page === page ? listing.result : undefined;
|
|
50
|
+
const detail = loaded?.selection === selection ? loaded?.result : undefined;
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
setListing(undefined);
|
|
54
|
+
void client.list(page, controller.signal).then(data => {
|
|
55
|
+
if (!controller.signal.aborted) setListing({page, result: {data}});
|
|
56
|
+
}, error => {
|
|
57
|
+
if (!controller.signal.aborted) setListing({page, result: {error: errorMessage(error, labels)}});
|
|
58
|
+
});
|
|
59
|
+
return () => controller.abort();
|
|
60
|
+
}, [client, page, labels]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!selection) return;
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
void client.load(selection.number, controller.signal).then(data => {
|
|
65
|
+
if (!controller.signal.aborted) setLoaded({selection, result: {data}});
|
|
66
|
+
}, error => {
|
|
67
|
+
if (!controller.signal.aborted) setLoaded({selection, result: {error: errorMessage(error, labels)}});
|
|
68
|
+
});
|
|
69
|
+
return () => controller.abort();
|
|
70
|
+
}, [client, selection, labels]);
|
|
71
|
+
const choose = (number: number) => {
|
|
72
|
+
if (!Number.isSafeInteger(number) || number < 1) return;
|
|
73
|
+
setSelection({number});
|
|
74
|
+
setInput(String(number));
|
|
75
|
+
};
|
|
76
|
+
const pr = detail?.data;
|
|
77
|
+
return <div className="twgit-content">
|
|
78
|
+
<aside>
|
|
79
|
+
<h3>{labels.title}</h3>
|
|
80
|
+
<p>{name}</p>
|
|
81
|
+
<form className="twgit-pr-number" onSubmit={event => {event.preventDefault(); choose(Number(input));}}>
|
|
82
|
+
<label>{labels.number}<input type="number" required min={1} max={Number.MAX_SAFE_INTEGER}
|
|
83
|
+
value={input} onChange={event => setInput(event.currentTarget.value)}/></label>
|
|
84
|
+
<button type="submit">{labels.openNumber}</button>
|
|
85
|
+
</form>
|
|
86
|
+
{!list && <p role="status">{labels.loading}</p>}
|
|
87
|
+
{list?.error && <p role="alert">{list.error}</p>}
|
|
88
|
+
{list?.data && <>
|
|
89
|
+
{!list.data.items.length && <p>{labels.empty}</p>}
|
|
90
|
+
<ul>{list.data.items.map(item => <li key={item.number}>
|
|
91
|
+
<button className="twgit-file" type="button" aria-pressed={selection?.number === item.number}
|
|
92
|
+
onClick={() => choose(item.number)}>#{item.number} {item.title}</button>
|
|
93
|
+
</li>)}</ul>
|
|
94
|
+
<div className="twgit-pr-paging">
|
|
95
|
+
<button type="button" disabled={page === 1} onClick={() => setPage(page - 1)}>{labels.prev}</button>
|
|
96
|
+
<button type="button" disabled={!list.data.more} onClick={() => setPage(page + 1)}>{labels.next}</button>
|
|
97
|
+
</div>
|
|
98
|
+
</>}
|
|
99
|
+
</aside>
|
|
100
|
+
<main className="twgit-diff">
|
|
101
|
+
{!selection && <p>{labels.select}</p>}
|
|
102
|
+
{selection && !detail && <p role="status">{labels.loading}</p>}
|
|
103
|
+
{detail?.error && <p role="alert">{detail.error}</p>}
|
|
104
|
+
{pr && <>
|
|
105
|
+
<p className="twgit-pr-revision">
|
|
106
|
+
<strong>#{pr.number} · {labels[pr.state]}</strong>
|
|
107
|
+
{pr.author && <span>{pr.author}</span>}
|
|
108
|
+
<code title={pr.head.sha}>{pr.head.ref} → {pr.base.ref}</code>
|
|
109
|
+
<a href={pr.url} target="_blank" rel="noreferrer">{labels.onGitHub}</a>
|
|
110
|
+
</p>
|
|
111
|
+
<ProjectSourceReview title={pr.title} description={pr.description} files={pr.files} renderBlock={renderBlock} locale={locale}/>
|
|
112
|
+
</>}
|
|
113
|
+
</main>
|
|
114
|
+
</div>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function PullRequestReviewPanel({repository, locale, renderBlock}: {
|
|
118
|
+
repository: Repository; locale: string; renderBlock: ProjectBlockRenderer;
|
|
119
|
+
}) {
|
|
120
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
121
|
+
const [origin, setOrigin] = useState<{repository: Repository; name: string | null; failed?: boolean}>();
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
let active = true;
|
|
124
|
+
void repository.readOrigin().then(value => {
|
|
125
|
+
if (active) setOrigin({repository, name: gitHubRepositoryFromOrigin(value)});
|
|
126
|
+
}, () => {
|
|
127
|
+
if (active) setOrigin({repository, name: null, failed: true});
|
|
128
|
+
});
|
|
129
|
+
return () => {active = false;};
|
|
130
|
+
}, [repository]);
|
|
131
|
+
if (origin?.repository !== repository) return <p role="status" className="twgit-context">{labels.loading}</p>;
|
|
132
|
+
if (!origin.name) return <p className="twgit-context" role="status">{origin.failed ? labels.failed : labels.noOrigin}</p>;
|
|
133
|
+
return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock}/>;
|
|
134
|
+
}
|