@kubohiroya/turbowarp-plus-editor-git 0.11.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/style.css CHANGED
@@ -106,6 +106,20 @@
106
106
  border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas));
107
107
  border-radius: 4px;
108
108
  }
109
+
110
+ /* The legend matches the outlines drawn on the previews. */
111
+ .twgit-review-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0; font-size: .9em; }
112
+ .twgit-review-legend span::before { content: ''; display: inline-block; width: 12px; height: 10px; margin-inline-end: 5px;
113
+ vertical-align: -1px; border: 2.5px solid var(--twgit-change); border-radius: 3px; }
114
+ .twgit-review-legend [data-legend="added"] { --twgit-change: #25875d; }
115
+ .twgit-review-legend [data-legend="removed"] { --twgit-change: #c54a53; }
116
+ .twgit-review-legend [data-legend="updated"] { --twgit-change: #635bce; }
117
+ .twgit-review-legend [data-legend="part"] { --twgit-change: #b4510a; }
118
+ .twgit-review-legend [data-legend="part"]::before { border-radius: 0; }
119
+ .twgit-review-summary { color: color-mix(in srgb, CanvasText 72%, Canvas); }
120
+ .twgit-review-target-status { font-weight: 600; }
121
+ .twgit-review-asset { display: flex; align-items: center; gap: 8px; }
122
+ .twgit-review-asset img { width: 32px; height: 32px; object-fit: contain; }
109
123
  :root{--diff-background-color:initial;--diff-text-color:initial;--diff-font-family:Consolas,Courier,monospace;--diff-selection-background-color:#b3d7ff;--diff-selection-text-color:var(--diff-text-color);--diff-gutter-insert-background-color:#d6fedb;--diff-gutter-insert-text-color:var(--diff-text-color);--diff-gutter-delete-background-color:#fadde0;--diff-gutter-delete-text-color:var(--diff-text-color);--diff-gutter-selected-background-color:#fffce0;--diff-gutter-selected-text-color:var(--diff-text-color);--diff-code-insert-background-color:#eaffee;--diff-code-insert-text-color:var(--diff-text-color);--diff-code-delete-background-color:#fdeff0;--diff-code-delete-text-color:var(--diff-text-color);--diff-code-insert-edit-background-color:#c0dc91;--diff-code-insert-edit-text-color:var(--diff-text-color);--diff-code-delete-edit-background-color:#f39ea2;--diff-code-delete-edit-text-color:var(--diff-text-color);--diff-code-selected-background-color:#fffce0;--diff-code-selected-text-color:var(--diff-text-color);--diff-omit-gutter-line-color:#cb2a1d;}.diff{background-color:var(--diff-background-color);border-collapse:collapse;color:var(--diff-text-color);table-layout:fixed;width:100%;}.diff::-moz-selection{background-color:#b3d7ff;background-color:var(--diff-selection-background-color);color:var(--diff-text-color);color:var(--diff-selection-text-color);}.diff::selection{background-color:#b3d7ff;background-color:var(--diff-selection-background-color);color:var(--diff-text-color);color:var(--diff-selection-text-color);}.diff td{padding-bottom:0;padding-top:0;vertical-align:top;}.diff-line{font-family:Consolas,Courier,monospace;font-family:var(--diff-font-family);line-height:1.5;}.diff-gutter>a{color:inherit;display:block;}.diff-gutter{cursor:pointer;padding:0 1ch;text-align:right;-webkit-user-select:none;-moz-user-select:none;user-select:none;}.diff-gutter-insert{background-color:#d6fedb;background-color:var(--diff-gutter-insert-background-color);color:var(--diff-text-color);color:var(--diff-gutter-insert-text-color);}.diff-gutter-delete{background-color:#fadde0;background-color:var(--diff-gutter-delete-background-color);color:var(--diff-text-color);color:var(--diff-gutter-delete-text-color);}.diff-gutter-omit{cursor:default;}.diff-gutter-selected{background-color:#fffce0;background-color:var(--diff-gutter-selected-background-color);color:var(--diff-text-color);color:var(--diff-gutter-selected-text-color);}.diff-code{word-wrap:break-word;padding:0 0 0 .5em;white-space:pre-wrap;word-break:break-all;}.diff-code-edit{color:inherit;}.diff-code-insert{background-color:#eaffee;background-color:var(--diff-code-insert-background-color);color:var(--diff-text-color);color:var(--diff-code-insert-text-color);}.diff-code-insert .diff-code-edit{background-color:#c0dc91;background-color:var(--diff-code-insert-edit-background-color);color:var(--diff-text-color);color:var(--diff-code-insert-edit-text-color);}.diff-code-delete{background-color:#fdeff0;background-color:var(--diff-code-delete-background-color);color:var(--diff-text-color);color:var(--diff-code-delete-text-color);}.diff-code-delete .diff-code-edit{background-color:#f39ea2;background-color:var(--diff-code-delete-edit-background-color);color:var(--diff-text-color);color:var(--diff-code-delete-edit-text-color);}.diff-code-selected{background-color:#fffce0;background-color:var(--diff-code-selected-background-color);color:var(--diff-text-color);color:var(--diff-code-selected-text-color);}.diff-widget-content{vertical-align:top;}.diff-gutter-col{width:7ch;}.diff-gutter-omit{height:0;}.diff-gutter-omit:before{background-color:#cb2a1d;background-color:var(--diff-omit-gutter-line-color);content:" ";display:block;height:100%;margin-left:4.6ch;overflow:hidden;white-space:pre;width:2px;}.diff-decoration{line-height:1.5;-webkit-user-select:none;-moz-user-select:none;user-select:none;}.diff-decoration-content{font-family:Consolas,Courier,monospace;font-family:var(--diff-font-family);padding:0;}.twgit-backdrop { position: fixed; inset: 0; z-index: 65000; background: #0006; display: flex; align-items: center; justify-content: center; }
110
124
  .twgit-panel { width: 94vw; height: 90vh; display: flex; flex-direction: column; background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 8px; box-shadow: 0 12px 48px #0005; font: 14px/1.5 system-ui, sans-serif; overflow: hidden; }
111
125
  .twgit-panel > header { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid GrayText; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-git",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -106,3 +106,17 @@
106
106
  border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas));
107
107
  border-radius: 4px;
108
108
  }
109
+
110
+ /* The legend matches the outlines drawn on the previews. */
111
+ .twgit-review-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0; font-size: .9em; }
112
+ .twgit-review-legend span::before { content: ''; display: inline-block; width: 12px; height: 10px; margin-inline-end: 5px;
113
+ vertical-align: -1px; border: 2.5px solid var(--twgit-change); border-radius: 3px; }
114
+ .twgit-review-legend [data-legend="added"] { --twgit-change: #25875d; }
115
+ .twgit-review-legend [data-legend="removed"] { --twgit-change: #c54a53; }
116
+ .twgit-review-legend [data-legend="updated"] { --twgit-change: #635bce; }
117
+ .twgit-review-legend [data-legend="part"] { --twgit-change: #b4510a; }
118
+ .twgit-review-legend [data-legend="part"]::before { border-radius: 0; }
119
+ .twgit-review-summary { color: color-mix(in srgb, CanvasText 72%, Canvas); }
120
+ .twgit-review-target-status { font-weight: 600; }
121
+ .twgit-review-asset { display: flex; align-items: center; gap: 8px; }
122
+ .twgit-review-asset img { width: 32px; height: 32px; object-fit: contain; }
@@ -15,6 +15,8 @@ export {createScratchBlocksDiffLocator} from './block-diff-location.js';
15
15
  export type {ProjectBlockLocation, ProjectBlockLocator, ScratchBlockLocationWorkspace} from './block-diff-location.js';
16
16
  export type {BlockPart} from './project-source-patch.js';
17
17
  export {createProjectSourceDiff} from './project-source-patch.js';
18
+ export {compareProjectForReview} from './project-source-review-model.js';
19
+ export type {ScriptChangeKind, ScriptReview, ScriptReviewSide, ScriptSummaryItem, TargetReview} from './project-source-review-model.js';
18
20
  export {currentCostume} from './project-assets.js';
19
21
  export {assetFileName, organizeProjectChanges, projectPropertyLabel} from './project-change-model.js';
20
22
  export type {ChangedFile, OrganizeProjectChangesInput, ProjectChangeList, ProjectChangeStatus, ProjectElementChange, ProjectElementKind,
@@ -3,14 +3,33 @@ import type {BlockChange, BlockSide, ProjectSource, ProjectTarget} from './proje
3
3
  import type {ScriptBlockMark} from './scratch-block-diff-renderer.js';
4
4
 
5
5
  export interface ScriptReviewSide extends BlockSide { marks: ScriptBlockMark[] }
6
+ /**
7
+ * What happened to one script. `moved` means only positions changed, `reordered` only how blocks connect;
8
+ * neither changes what the script does.
9
+ */
10
+ export type ScriptChangeKind = 'added' | 'removed' | 'updated' | 'reorganized' | 'moved' | 'reordered';
11
+ /** One phrase of a script's summary, in the order shown. The view words it in the reader's language. */
12
+ export type ScriptSummaryItem =
13
+ | {kind: 'hatReplaced'; before: string; after: string} // hat block opcodes
14
+ | {kind: 'wrapped'; opcode: string} // a new C block now holds blocks that existed before
15
+ | {kind: 'branchAdded'; opcode: string} // control_if or control_if_else added
16
+ | {kind: 'valueChanged'; opcode: string; name: string} // an input or field of an existing block, by its name
17
+ | {kind: 'blocksAdded'; count: number}
18
+ | {kind: 'blocksRemoved'; count: number}
19
+ | {kind: 'reordered'}
20
+ | {kind: 'moved'};
6
21
  export interface ScriptReview {
7
22
  id: string;
8
23
  before: ScriptReviewSide[];
9
24
  after: ScriptReviewSide[];
25
+ kind: ScriptChangeKind;
26
+ summary: ScriptSummaryItem[];
10
27
  }
11
28
  export interface TargetReview {
12
29
  name: string;
13
30
  isStage: boolean;
31
+ /** Added or removed as a whole, or changed. Added and removed targets list no properties. */
32
+ status: 'added' | 'removed' | 'updated';
14
33
  /** The stage or sprite in each version, absent where it does not exist. */
15
34
  before?: ProjectTarget;
16
35
  after?: ProjectTarget;
@@ -66,24 +85,149 @@ function groupScripts(before: ProjectTarget | undefined, after: ProjectTarget |
66
85
  for (const root of new Set(roots[version].values())) {
67
86
  const key = version + ':' + root;
68
87
  const id = groupOf(key);
69
- const group = groups.get(id) ?? {id, before: [], after: []};
88
+ const group = groups.get(id) ?? {id, before: [], after: [], kind: 'updated' as ScriptChangeKind, summary: [] as ScriptSummaryItem[]};
70
89
  const side: ScriptReviewSide = {target, id: root, marks: []};
71
90
  group[version].push(side);
72
91
  groups.set(id, group);
73
92
  sides.set(key, side);
74
93
  }
75
94
  }
76
- const affected = new Set<string>();
95
+ const affected = new Map<string, BlockChange[]>();
77
96
  for (const change of changes) {
78
97
  for (const version of ['before', 'after'] as const) {
79
98
  const block = change[version];
80
99
  if (!block) continue;
81
100
  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));
101
+ // Only content changes are marked; a block that was merely reconnected or moved looks the same.
102
+ if (!isLayoutOnly(change)) sides.get(key)?.marks.push({id: block.id, kind: change.kind, parts: change.parts});
103
+ const id = groupOf(key);
104
+ const list = affected.get(id) ?? [];
105
+ if (!list.includes(change)) list.push(change);
106
+ affected.set(id, list);
107
+ }
108
+ }
109
+ return [...affected].map(([id, list]) => {
110
+ const group = groups.get(id)!;
111
+ group.kind = scriptKind(group, list);
112
+ group.summary = summarize(group, list, before, after);
113
+ return group;
114
+ });
115
+ }
116
+
117
+ const connectionFields = new Set(['next', 'parent', 'topLevel']);
118
+ const positionFields = new Set(['x', 'y']);
119
+ // Compact top-level reporters are arrays: [type, name, id, x, y].
120
+ const compactPositionOnly = (a: unknown, b: unknown) =>
121
+ Array.isArray(a) && Array.isArray(b) && equal(a.slice(0, 3), b.slice(0, 3)) && !equal(a, b);
122
+
123
+ function blockOf(side: BlockSide | undefined) {
124
+ return side ? side.target.blocks[side.id] : undefined;
125
+ }
126
+
127
+ /** True for an updated block whose content is the same: only its connections or its position changed. */
128
+ function isLayoutOnly(change: BlockChange) {
129
+ if (change.kind !== 'updated') return false;
130
+ if (compactPositionOnly(blockOf(change.before), blockOf(change.after))) return true;
131
+ return change.fields.length > 0 && change.fields.every(field => connectionFields.has(field) || positionFields.has(field));
132
+ }
133
+
134
+ function isPositionOnly(change: BlockChange) {
135
+ if (change.kind !== 'updated') return false;
136
+ if (compactPositionOnly(blockOf(change.before), blockOf(change.after))) return true;
137
+ return change.fields.length > 0 && change.fields.every(field => positionFields.has(field));
138
+ }
139
+
140
+ function scriptKind(group: ScriptReview, changes: readonly BlockChange[]): ScriptChangeKind {
141
+ if (!group.before.length) return 'added';
142
+ if (!group.after.length) return 'removed';
143
+ if (group.before.length !== 1 || group.after.length !== 1) return 'reorganized';
144
+ if (changes.every(isPositionOnly)) return 'moved';
145
+ if (changes.every(isLayoutOnly)) return 'reordered';
146
+ return 'updated';
147
+ }
148
+
149
+ const opcodeOf = (block: unknown) => isRecord(block) && typeof block.opcode === 'string' ? block.opcode : undefined;
150
+ const isShadow = (block: unknown) => !isRecord(block) || block.shadow === true;
151
+ const isHat = (opcode: string | undefined) => opcode !== undefined &&
152
+ (opcode === 'control_start_as_clone' || opcode === 'procedures_definition' || /^[a-z0-9]+_when/i.test(opcode));
153
+ const substackInputs = ['SUBSTACK', 'SUBSTACK2'];
154
+
155
+ function inputBlock(block: unknown, name: string) {
156
+ if (!isRecord(block) || !isRecord(block.inputs)) return undefined;
157
+ const input = block.inputs[name];
158
+ return Array.isArray(input) && typeof input[1] === 'string' ? input[1] : undefined;
159
+ }
160
+
161
+ function summarize(group: ScriptReview, changes: readonly BlockChange[], before?: ProjectTarget, after?: ProjectTarget) {
162
+ const items: ScriptSummaryItem[] = [];
163
+ const seen = new Set<string>();
164
+ const add = (item: ScriptSummaryItem) => {
165
+ const key = JSON.stringify(item);
166
+ if (seen.has(key)) return;
167
+ seen.add(key);
168
+ items.push(item);
169
+ };
170
+ if (group.kind === 'moved' || group.kind === 'reordered') {
171
+ add({kind: group.kind});
172
+ return items;
173
+ }
174
+ const explained = new Set<BlockChange>();
175
+ if (group.before.length === 1 && group.after.length === 1) {
176
+ const oldRoot = group.before[0].id, newRoot = group.after[0].id;
177
+ const a = opcodeOf(before?.blocks[oldRoot]), b = opcodeOf(after?.blocks[newRoot]);
178
+ if (a !== b && isHat(a) && isHat(b)) {
179
+ add({kind: 'hatReplaced', before: a!, after: b!});
180
+ // The new hat and the one it replaced are the replacement, not separate additions and removals.
181
+ for (const change of changes) {
182
+ if ((change.kind === 'added' && change.after?.id === newRoot) || (change.kind === 'removed' && change.before?.id === oldRoot)) {
183
+ explained.add(change);
184
+ }
185
+ }
186
+ }
187
+ }
188
+ const branches: BlockChange[] = [];
189
+ for (const change of changes) {
190
+ if (change.kind !== 'added' || !after) continue;
191
+ const block = after.blocks[change.after!.id];
192
+ const opcode = opcodeOf(block);
193
+ if (!opcode) continue;
194
+ const wraps = substackInputs.some(name => {
195
+ const visited = new Set<string>();
196
+ for (let id = inputBlock(block, name); id !== undefined && !visited.has(id);) {
197
+ visited.add(id);
198
+ if (before && Object.hasOwn(before.blocks, id)) return true;
199
+ const next = after.blocks[id];
200
+ id = isRecord(next) && typeof next.next === 'string' ? next.next : undefined;
201
+ }
202
+ return false;
203
+ });
204
+ if (wraps) {
205
+ add({kind: 'wrapped', opcode});
206
+ explained.add(change);
207
+ } else if (opcode === 'control_if' || opcode === 'control_if_else') {
208
+ branches.push(change);
209
+ }
210
+ }
211
+ for (const change of branches) {
212
+ add({kind: 'branchAdded', opcode: opcodeOf(after!.blocks[change.after!.id])!});
213
+ explained.add(change);
214
+ }
215
+ for (const change of changes) {
216
+ if (change.kind !== 'updated') continue;
217
+ const opcode = opcodeOf(blockOf(change.after)) ?? opcodeOf(blockOf(change.before));
218
+ if (!opcode) continue;
219
+ for (const part of change.parts) {
220
+ // A C block's slot holds blocks; what changed there is reported as additions, removals or wrapping.
221
+ if (part.kind === 'input' && substackInputs.includes(part.name)) continue;
222
+ add({kind: 'valueChanged', opcode, name: part.name});
84
223
  }
85
224
  }
86
- return [...affected].map(id => groups.get(id)!);
225
+ const count = (kind: 'added' | 'removed') => changes.filter(change => change.kind === kind && !explained.has(change) &&
226
+ !isShadow(blockOf(kind === 'added' ? change.after : change.before))).length;
227
+ const added = count('added'), removed = count('removed');
228
+ if (added) add({kind: 'blocksAdded', count: added});
229
+ if (removed) add({kind: 'blocksRemoved', count: removed});
230
+ return items;
87
231
  }
88
232
 
89
233
  function changedProperties(before: Record<string, unknown>, after: Record<string, unknown>, except: string) {
@@ -106,10 +250,12 @@ export function compareProjectForReview(before: ProjectSource, after: ProjectSou
106
250
  const a = oldTargets.get(key), b = newTargets.get(key);
107
251
  const edits = byTarget.get(key);
108
252
  const scripts = edits ? groupScripts(a, b, edits) : [];
109
- const properties = changedProperties({...a}, {...b}, 'blocks');
110
- if (scripts.length || properties.length) {
253
+ const status = !a ? 'added' : !b ? 'removed' : 'updated';
254
+ // An added or removed target is shown as a whole; listing every key it has would say nothing more.
255
+ const properties = status === 'updated' ? changedProperties({...a}, {...b}, 'blocks') : [];
256
+ if (scripts.length || properties.length || status !== 'updated') {
111
257
  const {name, isStage} = (b ?? a)!;
112
- targets.push({name, isStage, scripts, properties, before: a, after: b});
258
+ targets.push({name, isStage, status, scripts, properties, before: a, after: b});
113
259
  }
114
260
  }
115
261
  return {targets, properties: changedProperties(before, after, 'targets')};
@@ -7,7 +7,9 @@ import type {ScriptReview, TargetReview} from './project-source-review-model.js'
7
7
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
8
8
  import type {ProjectBlockLocator} from './block-diff-location.js';
9
9
  import type {LocationStatus} from './block-diff-preview.js';
10
- import {currentCostume, useCostumeImage} from './project-assets.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';
11
13
  import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
12
14
 
13
15
  type Comparison = ReturnType<typeof compareProjectForReview>;
@@ -35,7 +37,11 @@ const ja = {
35
37
  review: '変更レビュー', fullDescription: 'PR 本文を表示', checks: '確認してほしいこと', files: '変更ファイル', targets: '影響するステージ・スプライト',
36
38
  scripts: 'コードの変更', scriptCount: (count: number) => `コードの変更 · ${count} か所`,
37
39
  reviewed: '確認済み', before: '変更前', after: '変更後',
38
- 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}』`,
39
45
  absent: 'この版にはこのコードがありません', loading: 'コードを描画中…',
40
46
  unavailable: 'このコードを描画できません。',
41
47
  related: '関連ファイル', properties: 'コード以外の変更', invalid: 'この作品の差分を読み込めません。',
@@ -49,7 +55,11 @@ const en: typeof ja = {
49
55
  review: 'Change review', fullDescription: 'Show full PR description', checks: 'Review checklist', files: 'Changed files', targets: 'Affected stages and sprites',
50
56
  scripts: 'Code changes', scriptCount: count => `Code changes · ${count}`,
51
57
  reviewed: 'Reviewed', before: 'Before', after: 'After',
52
- 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}`,
53
63
  absent: 'No code in this version', loading: 'Rendering code…',
54
64
  unavailable: 'This code could not be rendered.',
55
65
  related: 'Related file', properties: 'Changes outside the code', invalid: 'This project diff could not be read.',
@@ -71,15 +81,12 @@ const properties = new Map<string, readonly [string, string]>([
71
81
  function scriptTitle(side: BlockSide, labels: typeof ja) {
72
82
  const block = side.target.blocks[side.id];
73
83
  if (!isRecord(block)) return labels.script;
74
- if (block.opcode === 'event_whenflagclicked') return labels.flag;
75
- if (block.opcode === 'event_whenthisspriteclicked') return labels.click;
76
- if (block.opcode === 'event_whenkeypressed') return labels.key;
77
84
  if (block.opcode === 'event_whenbroadcastreceived') {
78
85
  const field = isRecord(block.fields) ? block.fields.BROADCAST_OPTION : undefined;
79
86
  const message = Array.isArray(field) && typeof field[0] === 'string' ? field[0] : '';
80
87
  if (message) return labels === ja ? `「${message}」${labels.broadcast}` : `“${message}” ${labels.broadcast}`;
81
88
  }
82
- return typeof block.opcode === 'string' ? block.opcode : labels.script;
89
+ return typeof block.opcode === 'string' && hatName(block.opcode, labels === ja ? 'ja' : 'en') || labels.script;
83
90
  }
84
91
 
85
92
  type BlockLocation = {locate?(side: BlockSide): void; status(side: BlockSide): LocationStatus | undefined};
@@ -118,8 +125,9 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
118
125
  }) {
119
126
  const [open, setOpen] = useState(false);
120
127
  const {sync, toggle} = usePreviewSync(labels === ja ? 'ja' : 'en');
121
- const status = !script.before.length ? 'added' : !script.after.length ? 'removed' :
122
- 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 ? '、' : ', ');
123
131
  const beforeTitle = script.before[0] && scriptTitle(script.before[0], labels);
124
132
  const afterTitle = script.after[0] && scriptTitle(script.after[0], labels);
125
133
  const title = beforeTitle && afterTitle && beforeTitle !== afterTitle ? beforeTitle + ' → ' + afterTitle : afterTitle ?? beforeTitle;
@@ -128,6 +136,7 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
128
136
  <span className="twgit-review-status">{labels[status]}</span>
129
137
  <strong>{title}</strong>
130
138
  {status === 'reorganized' && <small>{script.before.length} → {script.after.length}</small>}
139
+ {summary && <small className="twgit-review-summary">{summary}</small>}
131
140
  {reviewed && <small>{labels.reviewed}</small>}
132
141
  </summary>
133
142
  {open && <>
@@ -149,8 +158,16 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
149
158
  </details>;
150
159
  }
151
160
 
161
+ // Keys without a name people know are grouped as other settings; the keys stay in the title for those who look.
152
162
  function propertyNames(keys: readonly string[], labels: typeof ja) {
153
- 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>;
154
171
  }
155
172
 
156
173
  /**
@@ -186,8 +203,10 @@ function ComparisonTargets({comparison, labels, renderBlock, loadAsset, locateBl
186
203
  {!comparison.targets.length && !comparison.properties.length && <p>{labels.noChanges}</p>}
187
204
  {comparison.targets.map(target => <section className="twgit-review-target" key={JSON.stringify([target.isStage, target.name])}>
188
205
  <h5><TargetThumbnails target={target} labels={labels} loadAsset={loadAsset}/>
189
- {target.name}{target.scripts.length > 0 && <small>{labels.scriptCount(target.scripts.length)}</small>}</h5>
190
- {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>}
191
210
  {target.scripts.map(script => {
192
211
  const key = JSON.stringify([review?.file, target.isStage, target.name, script.id]);
193
212
  return <ScriptComparison key={key} script={script} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
@@ -197,6 +216,51 @@ function ComparisonTargets({comparison, labels, renderBlock, loadAsset, locateBl
197
216
  </>;
198
217
  }
199
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
+
200
264
  const countScripts = (comparison: Comparison) => comparison.targets.reduce((count, target) => count + target.scripts.length, 0);
201
265
 
202
266
  export interface ProjectSourceComparisonProps {
@@ -234,6 +298,7 @@ export function ProjectSourceComparison({before, after, renderBlock, locale = 'e
234
298
  if (!comparison) return fallback === undefined ? <p role="alert">{labels.invalid}</p> :
235
299
  <><p role="alert" className="twgit-review-unreadable">{labels.unreadable}</p>{fallback}</>;
236
300
  return <section className="twgit-block-diff twgit-review twgit-review-comparison" aria-label={labels.comparison}>
301
+ {!only && <Legend labels={labels}/>}
237
302
  {!only && <header className="twgit-review-overview">
238
303
  <dl>
239
304
  <div><dt>{labels.targets}</dt><dd>{comparison.targets.length}</dd></div>
@@ -289,10 +354,11 @@ export function ProjectSourceReview({title, description, verification, files, re
289
354
  <ul>{verification.map((item, index) => <li key={index}>{item}</li>)}</ul>
290
355
  </div>}
291
356
  </header>
357
+ <Legend labels={labels}/>
292
358
  <h3 className="twgit-review-files-title">{labels.files}</h3>
293
359
  {entries.map(({file, comparison, invalid}) => <section key={file.path} className="twgit-review-file">
294
360
  <header><h4>{file.previousPath ? file.previousPath + ' → ' + file.path : file.path}</h4>{file.summary && <p>{file.summary}</p>}</header>
295
- {'status' in file && <p>{labels.related} · {labels[file.status]}</p>}
361
+ {'status' in file && <RelatedFile file={file} entries={entries} labels={labels}/>}
296
362
  {(invalid || file.unavailable) && <p role="alert">{labels.invalid}</p>}
297
363
  {comparison && <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}
298
364
  loadAsset={'loadAsset' in file ? file.loadAsset : undefined}
@@ -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;