@kubohiroya/turbowarp-plus-editor-git 0.10.0 → 0.12.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/dist/editor-block-locator.d.ts +27 -0
- package/dist/file-diff-view.d.ts +3 -1
- package/dist/index.d.ts +25 -0
- package/dist/index.js +126 -32
- package/dist/project-change-list.d.ts +3 -1
- package/dist/project-source-diff.d.ts +2 -0
- package/dist/project-source-diff.js +2 -2
- package/dist/project-source-review-model.d.ts +35 -0
- package/dist/project-source-review.d.ts +6 -1
- package/dist/pull-request-panel.d.ts +4 -1
- package/dist/review-words.d.ts +6 -0
- package/dist/{scratch-block-diff-renderer-DZR8ep-T.js → scratch-block-diff-renderer-e_drVlHE.js} +598 -263
- package/dist/style.css +16 -0
- package/package.json +1 -1
- package/src/editor-block-locator.ts +55 -0
- package/src/file-diff-view.tsx +4 -3
- package/src/index.tsx +74 -16
- package/src/project-change-list.tsx +4 -3
- package/src/project-source-diff.css +14 -0
- package/src/project-source-diff.tsx +2 -0
- package/src/project-source-review-model.ts +154 -8
- package/src/project-source-review.tsx +136 -24
- package/src/pull-request-panel.tsx +9 -4
- package/src/review-words.ts +66 -0
- package/src/style.css +2 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import React, {useMemo, useState} from 'react';
|
|
1
|
+
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
|
2
2
|
import {BlockPreview, usePreviewSync} from './block-diff-preview.js';
|
|
3
3
|
import {isRecord, parseProjectSource, readProjectSourceDiff} from './project-source-patch.js';
|
|
4
4
|
import type {BlockSide} from './project-source-patch.js';
|
|
5
5
|
import {compareProjectForReview} from './project-source-review-model.js';
|
|
6
6
|
import type {ScriptReview, TargetReview} from './project-source-review-model.js';
|
|
7
7
|
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
8
|
-
import {
|
|
8
|
+
import type {ProjectBlockLocator} from './block-diff-location.js';
|
|
9
|
+
import type {LocationStatus} from './block-diff-preview.js';
|
|
10
|
+
import {currentCostume, useAssetImage, useCostumeImage} from './project-assets.js';
|
|
11
|
+
import {assetFileName, projectPropertyLabel} from './project-change-model.js';
|
|
12
|
+
import {hatName, legend, summaryPhrase} from './review-words.js';
|
|
9
13
|
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
10
14
|
|
|
11
15
|
type Comparison = ReturnType<typeof compareProjectForReview>;
|
|
@@ -15,7 +19,9 @@ export type ProjectSourceReviewFile = {
|
|
|
15
19
|
summary?: string;
|
|
16
20
|
previousPath?: string;
|
|
17
21
|
unavailable?: boolean;
|
|
18
|
-
} & ({patch: string; beforeSource?: string; loadAsset?: ProjectAssetLoader
|
|
22
|
+
} & ({patch: string; beforeSource?: string; loadAsset?: ProjectAssetLoader;
|
|
23
|
+
/** Offers "Highlight in Editor" on this file's after version, which the caller has matched with the open project. */
|
|
24
|
+
locateBlock?: ProjectBlockLocator} | {status: 'added' | 'removed' | 'updated'});
|
|
19
25
|
|
|
20
26
|
export interface ProjectSourceReviewProps {
|
|
21
27
|
title: string;
|
|
@@ -31,25 +37,37 @@ const ja = {
|
|
|
31
37
|
review: '変更レビュー', fullDescription: 'PR 本文を表示', checks: '確認してほしいこと', files: '変更ファイル', targets: '影響するステージ・スプライト',
|
|
32
38
|
scripts: 'コードの変更', scriptCount: (count: number) => `コードの変更 · ${count} か所`,
|
|
33
39
|
reviewed: '確認済み', before: '変更前', after: '変更後',
|
|
34
|
-
updated: '変更', added: '追加', removed: '削除', reorganized: '再編',
|
|
40
|
+
updated: '変更', added: '追加', removed: '削除', reorganized: '再編', moved: '位置だけ', reordered: '並べ替え',
|
|
41
|
+
stage: 'ステージ', otherSettings: 'そのほかの設定', legend: '色の見方',
|
|
42
|
+
targetAdded: (name: string, stage: boolean) => stage ? 'ステージを追加' : `スプライト『${name}』を追加`,
|
|
43
|
+
targetRemoved: (name: string, stage: boolean) => stage ? 'ステージを削除' : `スプライト『${name}』を削除`,
|
|
44
|
+
assetOwner: (owner: string, kind: 'costume' | 'backdrop' | 'sound', name: string) => `${owner} の${kind === 'sound' ? '音' : kind === 'backdrop' ? '背景' : 'コスチューム'}『${name}』`,
|
|
35
45
|
absent: 'この版にはこのコードがありません', loading: 'コードを描画中…',
|
|
36
46
|
unavailable: 'このコードを描画できません。',
|
|
37
47
|
related: '関連ファイル', properties: 'コード以外の変更', invalid: 'この作品の差分を読み込めません。',
|
|
38
48
|
flag: '緑の旗が押されたとき', broadcast: 'を受け取ったとき', key: 'キーが押されたとき',
|
|
39
49
|
click: 'このスプライトが押されたとき', script: 'コード', noChanges: '作品に内容の変更はありません。',
|
|
40
|
-
comparison: '作品の変更', unreadable: '作品データとして読み込めないため、テキストの差分を表示します。'
|
|
50
|
+
comparison: '作品の変更', unreadable: '作品データとして読み込めないため、テキストの差分を表示します。',
|
|
51
|
+
locate: '編集画面で強調', locating: '編集画面でブロックを検索中…', located: '編集画面で強調しました。',
|
|
52
|
+
missingLocation: '編集画面に該当する版またはブロックがありません。'
|
|
41
53
|
};
|
|
42
54
|
const en: typeof ja = {
|
|
43
55
|
review: 'Change review', fullDescription: 'Show full PR description', checks: 'Review checklist', files: 'Changed files', targets: 'Affected stages and sprites',
|
|
44
56
|
scripts: 'Code changes', scriptCount: count => `Code changes · ${count}`,
|
|
45
57
|
reviewed: 'Reviewed', before: 'Before', after: 'After',
|
|
46
|
-
updated: 'Changed', added: 'Added', removed: 'Removed', reorganized: 'Reorganized',
|
|
58
|
+
updated: 'Changed', added: 'Added', removed: 'Removed', reorganized: 'Reorganized', moved: 'Moved only', reordered: 'Reconnected',
|
|
59
|
+
stage: 'Stage', otherSettings: 'Other settings', legend: 'Colours',
|
|
60
|
+
targetAdded: (name, stage) => stage ? 'Added the stage' : `Added the sprite “${name}”`,
|
|
61
|
+
targetRemoved: (name, stage) => stage ? 'Removed the stage' : `Removed the sprite “${name}”`,
|
|
62
|
+
assetOwner: (owner, kind, name) => `${kind === 'sound' ? 'Sound' : kind === 'backdrop' ? 'Backdrop' : 'Costume'} “${name}” of ${owner}`,
|
|
47
63
|
absent: 'No code in this version', loading: 'Rendering code…',
|
|
48
64
|
unavailable: 'This code could not be rendered.',
|
|
49
65
|
related: 'Related file', properties: 'Changes outside the code', invalid: 'This project diff could not be read.',
|
|
50
66
|
flag: 'When green flag clicked', broadcast: 'received', key: 'When key pressed',
|
|
51
67
|
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.'
|
|
68
|
+
comparison: 'Project changes', unreadable: 'This file could not be read as project data, so its text diff is shown.',
|
|
69
|
+
locate: 'Highlight in Editor', locating: 'Finding the block in the Editor…', located: 'Highlighted in the Editor.',
|
|
70
|
+
missingLocation: 'This version or block is unavailable in the Editor.'
|
|
53
71
|
};
|
|
54
72
|
|
|
55
73
|
const properties = new Map<string, readonly [string, string]>([
|
|
@@ -63,25 +81,53 @@ const properties = new Map<string, readonly [string, string]>([
|
|
|
63
81
|
function scriptTitle(side: BlockSide, labels: typeof ja) {
|
|
64
82
|
const block = side.target.blocks[side.id];
|
|
65
83
|
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
84
|
if (block.opcode === 'event_whenbroadcastreceived') {
|
|
70
85
|
const field = isRecord(block.fields) ? block.fields.BROADCAST_OPTION : undefined;
|
|
71
86
|
const message = Array.isArray(field) && typeof field[0] === 'string' ? field[0] : '';
|
|
72
87
|
if (message) return labels === ja ? `「${message}」${labels.broadcast}` : `“${message}” ${labels.broadcast}`;
|
|
73
88
|
}
|
|
74
|
-
return typeof block.opcode === 'string'
|
|
89
|
+
return typeof block.opcode === 'string' && hatName(block.opcode, labels === ja ? 'ja' : 'en') || labels.script;
|
|
75
90
|
}
|
|
76
91
|
|
|
77
|
-
|
|
92
|
+
type BlockLocation = {locate?(side: BlockSide): void; status(side: BlockSide): LocationStatus | undefined};
|
|
93
|
+
const sideKey = (side: BlockSide) => JSON.stringify([side.target.isStage, side.target.name, side.id]);
|
|
94
|
+
|
|
95
|
+
/** Highlights the after version of a script in the Editor, one request per comparison, and cancels it on unmount. */
|
|
96
|
+
function useBlockLocation(comparison: Comparison, locateBlock?: ProjectBlockLocator): BlockLocation {
|
|
97
|
+
const request = useRef<AbortController>();
|
|
98
|
+
const [location, setLocation] = useState<{comparison: Comparison; key: string; locator: ProjectBlockLocator; status: LocationStatus}>();
|
|
99
|
+
useEffect(() => () => request.current?.abort(), [comparison, locateBlock]);
|
|
100
|
+
const locate = async (side: BlockSide) => {
|
|
101
|
+
if (!locateBlock) return;
|
|
102
|
+
request.current?.abort();
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
request.current = controller;
|
|
105
|
+
const selected = {comparison, key: sideKey(side), locator: locateBlock};
|
|
106
|
+
setLocation({...selected, status: 'locating'});
|
|
107
|
+
let found = false;
|
|
108
|
+
try {
|
|
109
|
+
found = await locateBlock({target: side.target, id: side.id, version: 'after', parts: [], signal: controller.signal});
|
|
110
|
+
} catch {
|
|
111
|
+
// The host may replace the active project while resolving its workspace.
|
|
112
|
+
}
|
|
113
|
+
if (!controller.signal.aborted) setLocation({...selected, status: found ? 'located' : 'missingLocation'});
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
locate: locateBlock && (side => void locate(side)),
|
|
117
|
+
status: side => location?.comparison === comparison && location.locator === locateBlock && location.key === sideKey(side) ?
|
|
118
|
+
location.status : undefined
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset, location}: {
|
|
78
123
|
script: ScriptReview; labels: typeof ja; reviewed?: boolean; setReviewed?(value: boolean): void;
|
|
79
|
-
renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
|
|
124
|
+
renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader; location?: BlockLocation;
|
|
80
125
|
}) {
|
|
81
126
|
const [open, setOpen] = useState(false);
|
|
82
127
|
const {sync, toggle} = usePreviewSync(labels === ja ? 'ja' : 'en');
|
|
83
|
-
const status =
|
|
84
|
-
|
|
128
|
+
const status = script.kind;
|
|
129
|
+
const locale = labels === ja ? 'ja' : 'en';
|
|
130
|
+
const summary = script.summary.map(item => summaryPhrase(item, locale)).join(labels === ja ? '、' : ', ');
|
|
85
131
|
const beforeTitle = script.before[0] && scriptTitle(script.before[0], labels);
|
|
86
132
|
const afterTitle = script.after[0] && scriptTitle(script.after[0], labels);
|
|
87
133
|
const title = beforeTitle && afterTitle && beforeTitle !== afterTitle ? beforeTitle + ' → ' + afterTitle : afterTitle ?? beforeTitle;
|
|
@@ -90,6 +136,7 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
|
|
|
90
136
|
<span className="twgit-review-status">{labels[status]}</span>
|
|
91
137
|
<strong>{title}</strong>
|
|
92
138
|
{status === 'reorganized' && <small>{script.before.length} → {script.after.length}</small>}
|
|
139
|
+
{summary && <small className="twgit-review-summary">{summary}</small>}
|
|
93
140
|
{reviewed && <small>{labels.reviewed}</small>}
|
|
94
141
|
</summary>
|
|
95
142
|
{open && <>
|
|
@@ -98,7 +145,9 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
|
|
|
98
145
|
{script[version].length ? script[version].map((side, index, sides) => <BlockPreview key={side.id} side={side}
|
|
99
146
|
title={sides.length > 1 ? `${labels[version]} ${index + 1}` : labels[version]}
|
|
100
147
|
marks={side.marks} renderBlock={renderBlock} withContext labels={labels}
|
|
101
|
-
version={version} loadAsset={loadAsset} sync={sync}
|
|
148
|
+
version={version} loadAsset={loadAsset} sync={sync}
|
|
149
|
+
onLocate={version === 'after' && location?.locate ? () => location.locate!(side) : undefined}
|
|
150
|
+
locationStatus={version === 'after' ? location?.status(side) : undefined}/>) :
|
|
102
151
|
<BlockPreview title={labels[version]} renderBlock={renderBlock} withContext labels={labels}/>}
|
|
103
152
|
</div>)}
|
|
104
153
|
</div>
|
|
@@ -109,8 +158,16 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
|
|
|
109
158
|
</details>;
|
|
110
159
|
}
|
|
111
160
|
|
|
161
|
+
// Keys without a name people know are grouped as other settings; the keys stay in the title for those who look.
|
|
112
162
|
function propertyNames(keys: readonly string[], labels: typeof ja) {
|
|
113
|
-
|
|
163
|
+
const locale = labels === ja ? 'ja' : 'en';
|
|
164
|
+
const named = new Set<string>(), other: string[] = [];
|
|
165
|
+
for (const key of keys) {
|
|
166
|
+
const label = properties.get(key)?.[labels === ja ? 0 : 1] ?? projectPropertyLabel(key, locale);
|
|
167
|
+
if (label === key) other.push(key);
|
|
168
|
+
else named.add(label);
|
|
169
|
+
}
|
|
170
|
+
return <span title={keys.join(', ')}>{[...named, ...(other.length ? [labels.otherSettings] : [])].join(' · ')}</span>;
|
|
114
171
|
}
|
|
115
172
|
|
|
116
173
|
/**
|
|
@@ -135,26 +192,75 @@ function TargetThumbnails({target, labels, loadAsset}: {target: TargetReview; la
|
|
|
135
192
|
}
|
|
136
193
|
|
|
137
194
|
/** Lists the changed stages and sprites of one project file, each with its code comparisons. */
|
|
138
|
-
function ComparisonTargets({comparison, labels, renderBlock, loadAsset, review}: {
|
|
195
|
+
function ComparisonTargets({comparison, labels, renderBlock, loadAsset, locateBlock, review}: {
|
|
139
196
|
comparison: Comparison; labels: typeof ja; renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
|
|
197
|
+
locateBlock?: ProjectBlockLocator;
|
|
140
198
|
review?: {file: string; keys: ReadonlySet<string>; set(key: string, value: boolean): void};
|
|
141
199
|
}) {
|
|
200
|
+
const location = useBlockLocation(comparison, locateBlock);
|
|
142
201
|
return <>
|
|
143
202
|
{comparison.properties.length > 0 && <p>{labels.properties} · {propertyNames(comparison.properties, labels)}</p>}
|
|
144
203
|
{!comparison.targets.length && !comparison.properties.length && <p>{labels.noChanges}</p>}
|
|
145
204
|
{comparison.targets.map(target => <section className="twgit-review-target" key={JSON.stringify([target.isStage, target.name])}>
|
|
146
205
|
<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.
|
|
206
|
+
{target.isStage ? labels.stage : target.name}{target.scripts.length > 0 && <small>{labels.scriptCount(target.scripts.length)}</small>}</h5>
|
|
207
|
+
{target.status !== 'updated' && <p className="twgit-review-target-status">
|
|
208
|
+
{(target.status === 'added' ? labels.targetAdded : labels.targetRemoved)(target.name, target.isStage)}</p>}
|
|
209
|
+
{target.status === 'updated' && target.properties.length > 0 && <p>{labels.properties} · {propertyNames(target.properties, labels)}</p>}
|
|
149
210
|
{target.scripts.map(script => {
|
|
150
211
|
const key = JSON.stringify([review?.file, target.isStage, target.name, script.id]);
|
|
151
212
|
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))}/>;
|
|
213
|
+
location={location} reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}/>;
|
|
153
214
|
})}
|
|
154
215
|
</section>)}
|
|
155
216
|
</>;
|
|
156
217
|
}
|
|
157
218
|
|
|
219
|
+
function Legend({labels}: {labels: typeof ja}) {
|
|
220
|
+
return <p className="twgit-review-legend" aria-label={labels.legend}>
|
|
221
|
+
{legend(labels === ja ? 'ja' : 'en').map(([kind, text]) => <span key={kind} data-legend={kind}>{text}</span>)}
|
|
222
|
+
</p>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type RelatedEntry = {file: ProjectSourceReviewFile; comparison?: Comparison};
|
|
226
|
+
|
|
227
|
+
/** Finds who uses an asset file of the review: the costume or sound whose file name it is, in the project beside it. */
|
|
228
|
+
function assetOwner(path: string, status: 'added' | 'removed' | 'updated', entries: readonly RelatedEntry[]) {
|
|
229
|
+
const parts = path.split('/');
|
|
230
|
+
const name = parts.pop()!;
|
|
231
|
+
const folder = parts.slice(0, -1).join('/');
|
|
232
|
+
const version: ProjectVersion = status === 'removed' ? 'before' : 'after';
|
|
233
|
+
for (const {file, comparison} of entries) {
|
|
234
|
+
if (!comparison || !('patch' in file) || file.path.split('/').slice(0, -1).join('/') !== folder) continue;
|
|
235
|
+
for (const target of comparison.targets) {
|
|
236
|
+
const owner = target[version];
|
|
237
|
+
if (!owner) continue;
|
|
238
|
+
for (const kind of ['costumes', 'sounds'] as const) {
|
|
239
|
+
const list = (owner as unknown as Record<string, unknown>)[kind];
|
|
240
|
+
if (!Array.isArray(list)) continue;
|
|
241
|
+
const entry = list.find(item => isRecord(item) && assetFileName(item) === name);
|
|
242
|
+
if (isRecord(entry)) return {target, version, md5ext: name, loadAsset: file.loadAsset,
|
|
243
|
+
kind: kind === 'sounds' ? 'sound' as const : target.isStage ? 'backdrop' as const : 'costume' as const,
|
|
244
|
+
name: typeof entry.name === 'string' ? entry.name : name};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function RelatedFile({file, entries, labels}: {
|
|
252
|
+
file: ProjectSourceReviewFile & {status: 'added' | 'removed' | 'updated'}; entries: readonly RelatedEntry[]; labels: typeof ja;
|
|
253
|
+
}) {
|
|
254
|
+
const owner = assetOwner(file.path, file.status, entries);
|
|
255
|
+
const image = useAssetImage(owner && owner.kind !== 'sound' ? owner.md5ext : undefined, owner?.version ?? 'after', owner?.loadAsset);
|
|
256
|
+
if (!owner) return <p>{labels.related} · {labels[file.status]}</p>;
|
|
257
|
+
const ownerName = owner.target.isStage ? labels.stage : owner.target.name;
|
|
258
|
+
return <p className="twgit-review-asset">
|
|
259
|
+
{image && <img src={image} alt=""/>}
|
|
260
|
+
<span>{labels.assetOwner(ownerName, owner.kind, owner.name)} · {labels[file.status]}</span>
|
|
261
|
+
</p>;
|
|
262
|
+
}
|
|
263
|
+
|
|
158
264
|
const countScripts = (comparison: Comparison) => comparison.targets.reduce((count, target) => count + target.scripts.length, 0);
|
|
159
265
|
|
|
160
266
|
export interface ProjectSourceComparisonProps {
|
|
@@ -168,12 +274,14 @@ export interface ProjectSourceComparisonProps {
|
|
|
168
274
|
fallback?: React.ReactNode;
|
|
169
275
|
/** Reads the images of both versions for thumbnails. Without it, stages and sprites are shown by name. */
|
|
170
276
|
loadAsset?: ProjectAssetLoader;
|
|
277
|
+
/** Offers "Highlight in Editor" on the after version, which the caller has matched with the open project. */
|
|
278
|
+
locateBlock?: ProjectBlockLocator;
|
|
171
279
|
/** Shows only the code of this stage or sprite, without the overview and the changes outside the code. */
|
|
172
280
|
target?: {isStage: boolean; name: string};
|
|
173
281
|
}
|
|
174
282
|
|
|
175
283
|
/** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
|
|
176
|
-
export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset, target}: ProjectSourceComparisonProps) {
|
|
284
|
+
export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset, locateBlock, target}: ProjectSourceComparisonProps) {
|
|
177
285
|
const labels = locale.startsWith('ja') ? ja : en;
|
|
178
286
|
const only = target && JSON.stringify([target.isStage, target.isStage || target.name]);
|
|
179
287
|
const comparison = useMemo(() => {
|
|
@@ -190,13 +298,15 @@ export function ProjectSourceComparison({before, after, renderBlock, locale = 'e
|
|
|
190
298
|
if (!comparison) return fallback === undefined ? <p role="alert">{labels.invalid}</p> :
|
|
191
299
|
<><p role="alert" className="twgit-review-unreadable">{labels.unreadable}</p>{fallback}</>;
|
|
192
300
|
return <section className="twgit-block-diff twgit-review twgit-review-comparison" aria-label={labels.comparison}>
|
|
301
|
+
{!only && <Legend labels={labels}/>}
|
|
193
302
|
{!only && <header className="twgit-review-overview">
|
|
194
303
|
<dl>
|
|
195
304
|
<div><dt>{labels.targets}</dt><dd>{comparison.targets.length}</dd></div>
|
|
196
305
|
<div><dt>{labels.scripts}</dt><dd>{countScripts(comparison)}</dd></div>
|
|
197
306
|
</dl>
|
|
198
307
|
</header>}
|
|
199
|
-
<ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
|
|
308
|
+
<ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
|
|
309
|
+
locateBlock={locateBlock}/>
|
|
200
310
|
</section>;
|
|
201
311
|
}
|
|
202
312
|
|
|
@@ -244,13 +354,15 @@ export function ProjectSourceReview({title, description, verification, files, re
|
|
|
244
354
|
<ul>{verification.map((item, index) => <li key={index}>{item}</li>)}</ul>
|
|
245
355
|
</div>}
|
|
246
356
|
</header>
|
|
357
|
+
<Legend labels={labels}/>
|
|
247
358
|
<h3 className="twgit-review-files-title">{labels.files}</h3>
|
|
248
359
|
{entries.map(({file, comparison, invalid}) => <section key={file.path} className="twgit-review-file">
|
|
249
360
|
<header><h4>{file.previousPath ? file.previousPath + ' → ' + file.path : file.path}</h4>{file.summary && <p>{file.summary}</p>}</header>
|
|
250
|
-
{'status' in file && <
|
|
361
|
+
{'status' in file && <RelatedFile file={file} entries={entries} labels={labels}/>}
|
|
251
362
|
{(invalid || file.unavailable) && <p role="alert">{labels.invalid}</p>}
|
|
252
363
|
{comparison && <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}
|
|
253
364
|
loadAsset={'loadAsset' in file ? file.loadAsset : undefined}
|
|
365
|
+
locateBlock={'locateBlock' in file ? file.locateBlock : undefined}
|
|
254
366
|
review={{file: file.path, keys: reviewed, set: setReviewed}}/>}
|
|
255
367
|
</section>)}
|
|
256
368
|
</section>;
|
|
@@ -4,6 +4,7 @@ import type {GitHubPullRequests} from './github-pull-requests.js';
|
|
|
4
4
|
import {ProjectSourceReview} from './project-source-review.js';
|
|
5
5
|
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
6
6
|
import type {Repository} from './repository.js';
|
|
7
|
+
import type {ProjectBlockLocator} from './block-diff-location.js';
|
|
7
8
|
|
|
8
9
|
const en = {
|
|
9
10
|
title: 'Pull requests', number: 'Pull request number', openNumber: 'Open pull request',
|
|
@@ -37,8 +38,9 @@ type Result<T> = {data: T; error?: never} | {data?: never; error: string};
|
|
|
37
38
|
type Listing = Awaited<ReturnType<GitHubPullRequests['list']>>;
|
|
38
39
|
type Pull = Awaited<ReturnType<GitHubPullRequests['load']>>;
|
|
39
40
|
|
|
40
|
-
function PullRequests({name, labels, locale, renderBlock}: {
|
|
41
|
+
function PullRequests({name, labels, locale, renderBlock, locateFor}: {
|
|
41
42
|
name: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
|
|
43
|
+
locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
|
|
42
44
|
}) {
|
|
43
45
|
const client = useMemo(() => createGitHubPullRequests(name), [name]);
|
|
44
46
|
const [page, setPage] = useState(1);
|
|
@@ -108,14 +110,17 @@ function PullRequests({name, labels, locale, renderBlock}: {
|
|
|
108
110
|
<code title={pr.head.sha}>{pr.head.ref} → {pr.base.ref}</code>
|
|
109
111
|
<a href={pr.url} target="_blank" rel="noreferrer">{labels.onGitHub}</a>
|
|
110
112
|
</p>
|
|
111
|
-
<ProjectSourceReview title={pr.title} description={pr.description} files={pr.files
|
|
113
|
+
<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}
|
|
114
|
+
renderBlock={renderBlock} locale={locale}/>
|
|
112
115
|
</>}
|
|
113
116
|
</main>
|
|
114
117
|
</div>;
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
export function PullRequestReviewPanel({repository, locale, renderBlock}: {
|
|
120
|
+
export function PullRequestReviewPanel({repository, locale, renderBlock, locateFor}: {
|
|
118
121
|
repository: Repository; locale: string; renderBlock: ProjectBlockRenderer;
|
|
122
|
+
/** Returns a locator for a project file whose PR head matches the open project, or `undefined`. */
|
|
123
|
+
locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
|
|
119
124
|
}) {
|
|
120
125
|
const labels = locale.startsWith('ja') ? ja : en;
|
|
121
126
|
const [origin, setOrigin] = useState<{repository: Repository; name: string | null; failed?: boolean}>();
|
|
@@ -130,5 +135,5 @@ export function PullRequestReviewPanel({repository, locale, renderBlock}: {
|
|
|
130
135
|
}, [repository]);
|
|
131
136
|
if (origin?.repository !== repository) return <p role="status" className="twgit-context">{labels.loading}</p>;
|
|
132
137
|
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}/>;
|
|
138
|
+
return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor}/>;
|
|
134
139
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {ScriptSummaryItem} from './project-source-review-model.js';
|
|
2
|
+
|
|
3
|
+
type Pair = readonly [string, string];
|
|
4
|
+
const pick = ([ja, en]: Pair, locale: string) => locale.startsWith('ja') ? ja : en;
|
|
5
|
+
|
|
6
|
+
// Hat blocks name a script, as people read it in the Editor.
|
|
7
|
+
const hats = new Map<string, Pair>([
|
|
8
|
+
['event_whenflagclicked', ['緑の旗が押されたとき', 'When green flag clicked']],
|
|
9
|
+
['event_whenthisspriteclicked', ['このスプライトが押されたとき', 'When this sprite clicked']],
|
|
10
|
+
['event_whenstageclicked', ['ステージが押されたとき', 'When stage clicked']],
|
|
11
|
+
['event_whenkeypressed', ['キーが押されたとき', 'When key pressed']],
|
|
12
|
+
['event_whenbackdropswitchesto', ['背景が切り替わったとき', 'When backdrop switches']],
|
|
13
|
+
['event_whengreaterthan', ['値が超えたとき', 'When value exceeds']],
|
|
14
|
+
['event_whenbroadcastreceived', ['メッセージを受け取ったとき', 'When message received']],
|
|
15
|
+
['control_start_as_clone', ['クローンされたとき', 'When I start as a clone']],
|
|
16
|
+
['procedures_definition', ['定義', 'Define']]
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// Only blocks that summaries name; anything else is called a block.
|
|
20
|
+
const blocks = new Map<string, Pair>([
|
|
21
|
+
['control_repeat', ['〜回繰り返す', 'repeat'], ], ['control_forever', ['ずっと', 'forever']],
|
|
22
|
+
['control_repeat_until', ['〜まで繰り返す', 'repeat until']], ['control_while', ['〜の間繰り返す', 'while']],
|
|
23
|
+
['control_if', ['もし〜なら', 'if']], ['control_if_else', ['もし〜なら、でなければ', 'if else']],
|
|
24
|
+
['control_wait', ['〜秒待つ', 'wait']], ['motion_movesteps', ['〜歩動かす', 'move steps']],
|
|
25
|
+
['motion_turnright', ['〜度回す', 'turn']], ['motion_turnleft', ['〜度回す', 'turn']], ['motion_gotoxy', ['x座標を〜、y座標を〜にする', 'go to x y']],
|
|
26
|
+
['motion_changeyby', ['y座標を〜ずつ変える', 'change y by']], ['motion_changexby', ['x座標を〜ずつ変える', 'change x by']],
|
|
27
|
+
['motion_glidesecstoxy', ['〜秒でx座標を〜に、y座標を〜に変える', 'glide']],
|
|
28
|
+
['looks_sayforsecs', ['〜と〜秒言う', 'say for secs']], ['looks_say', ['〜と言う', 'say']],
|
|
29
|
+
['looks_switchcostumeto', ['コスチュームを〜にする', 'switch costume']], ['looks_nextcostume', ['次のコスチュームにする', 'next costume']],
|
|
30
|
+
['data_setvariableto', ['〜を〜にする', 'set variable']], ['data_changevariableby', ['〜を〜ずつ変える', 'change variable']],
|
|
31
|
+
['event_broadcast', ['〜を送る', 'broadcast']], ['sound_play', ['〜の音を鳴らす', 'play sound']]
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export function hatName(opcode: string, locale: string): string | undefined {
|
|
35
|
+
const pair = hats.get(opcode);
|
|
36
|
+
return pair && pick(pair, locale);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const blockName = (opcode: string, locale: string) => {
|
|
40
|
+
const pair = blocks.get(opcode) ?? hats.get(opcode);
|
|
41
|
+
return pair ? pick(pair, locale) : locale.startsWith('ja') ? 'ブロック' : 'a block';
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** One phrase per summary item, in the reader's language. */
|
|
45
|
+
export function summaryPhrase(item: ScriptSummaryItem, locale: string): string {
|
|
46
|
+
const ja = locale.startsWith('ja');
|
|
47
|
+
switch (item.kind) {
|
|
48
|
+
case 'hatReplaced': return ja ? `「${blockName(item.before, locale)}」を「${blockName(item.after, locale)}」に替えた` :
|
|
49
|
+
`replaced “${blockName(item.before, locale)}” with “${blockName(item.after, locale)}”`;
|
|
50
|
+
case 'wrapped': return ja ? `「${blockName(item.opcode, locale)}」で包んだ` : `wrapped in “${blockName(item.opcode, locale)}”`;
|
|
51
|
+
case 'branchAdded': return ja ? `分岐「${blockName(item.opcode, locale)}」を追加` : `added “${blockName(item.opcode, locale)}”`;
|
|
52
|
+
case 'valueChanged': return ja ? `「${blockName(item.opcode, locale)}」の ${item.name} を変更` :
|
|
53
|
+
`changed ${item.name} of “${blockName(item.opcode, locale)}”`;
|
|
54
|
+
case 'blocksAdded': return ja ? `ブロックを ${item.count} 個追加` : `added ${item.count} ${item.count === 1 ? 'block' : 'blocks'}`;
|
|
55
|
+
case 'blocksRemoved': return ja ? `ブロックを ${item.count} 個削除` : `removed ${item.count} ${item.count === 1 ? 'block' : 'blocks'}`;
|
|
56
|
+
case 'reordered': return ja ? 'つなぎ方だけを変更' : 'only reconnected';
|
|
57
|
+
case 'moved': return ja ? '位置だけを変更' : 'only moved';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The colours of the previews, said once at the top of a review. */
|
|
62
|
+
export const legend = (locale: string) => locale.startsWith('ja') ?
|
|
63
|
+
[['added', '緑の縁: 追加したブロック'], ['removed', '赤の縁: 削除したブロック'], ['updated', '紫の縁: 中身を変えたブロック'],
|
|
64
|
+
['part', 'オレンジの枠: 値を変えた欄']] as const :
|
|
65
|
+
[['added', 'Green outline: added block'], ['removed', 'Red outline: removed block'], ['updated', 'Purple outline: block with changed contents'],
|
|
66
|
+
['part', 'Orange frame: changed value']] as const;
|
package/src/style.css
CHANGED
|
@@ -55,3 +55,5 @@
|
|
|
55
55
|
.twgit-element-files { margin-top: 12px; }
|
|
56
56
|
.twgit-panel .twgit-element span.twgit-element-name { width: auto; font-family: inherit; }
|
|
57
57
|
.twgit-git-details > .twgit-connection-body { left: auto; right: 0; }
|
|
58
|
+
/* In a layout tab the panel fills the tab and leaves the Editor beside it. */
|
|
59
|
+
.twgit-panel.twgit-panel-tab { width: 100%; height: 100%; border: 0; border-radius: 0; box-shadow: none; }
|