@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.
@@ -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 {currentCostume, useCostumeImage} from './project-assets.js';
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} | {status: 'added' | 'removed' | 'updated'});
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' ? block.opcode : labels.script;
89
+ return typeof block.opcode === 'string' && hatName(block.opcode, labels === ja ? 'ja' : 'en') || labels.script;
75
90
  }
76
91
 
77
- function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset}: {
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 = !script.before.length ? 'added' : !script.after.length ? 'removed' :
84
- script.before.length !== 1 || script.after.length !== 1 ? 'reorganized' : 'updated';
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
- return keys.map(key => properties.get(key)?.[labels === ja ? 0 : 1] ?? key).join(' · ');
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.properties.length > 0 && <p>{labels.properties} · {propertyNames(target.properties, labels)}</p>}
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 && <p>{labels.related} · {labels[file.status]}</p>}
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} renderBlock={renderBlock} locale={locale}/>
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; }