@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/index.js +1 -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/review-words.d.ts +6 -0
- package/dist/{scratch-block-diff-renderer-CgAGfmAc.js → scratch-block-diff-renderer-e_drVlHE.js} +533 -254
- package/dist/style.css +14 -0
- package/package.json +1 -1
- 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 +79 -13
- package/src/review-words.ts +66 -0
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
|
@@ -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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
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'
|
|
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 =
|
|
122
|
-
|
|
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
|
-
|
|
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.
|
|
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 && <
|
|
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;
|