@kubohiroya/turbowarp-plus-editor-git 0.11.0 → 0.12.1
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/README.ja.md +2 -0
- package/README.md +2 -0
- package/dist/index.js +37 -11
- 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 +2 -1
- package/src/index.tsx +40 -5
- 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/test/smart-http-fixture.js +178 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubohiroya/turbowarp-plus-editor-git",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Read-only local Git panel for TurboWarp Plus Editor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"tsconfig.json",
|
|
37
37
|
"README.ja.md",
|
|
38
38
|
"THIRD_PARTY_NOTICES.md",
|
|
39
|
+
"test/smart-http-fixture.js",
|
|
39
40
|
"README.md",
|
|
40
41
|
"LICENSE"
|
|
41
42
|
],
|
package/src/index.tsx
CHANGED
|
@@ -93,6 +93,9 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
93
93
|
const alive = useRef(true);
|
|
94
94
|
const opening = useRef(false);
|
|
95
95
|
const dialog = useRef<HTMLDivElement>(null);
|
|
96
|
+
// A closed panel reads nothing; a refresh asked while it is closed waits until it opens again.
|
|
97
|
+
const shownRef = useRef(false);
|
|
98
|
+
const deferred = useRef(false);
|
|
96
99
|
const reviewing = useRef(review);
|
|
97
100
|
reviewing.current = review;
|
|
98
101
|
// The last fingerprint acted on, so a failing read or an open diff is not retried on every check.
|
|
@@ -115,10 +118,20 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
115
118
|
} catch (error) {
|
|
116
119
|
if (epoch.current !== ticket) return;
|
|
117
120
|
const code = error instanceof GitPanelError ? error.code : 'failed';
|
|
121
|
+
// A background read that met another tool still writing tries again on the next check, without an alert.
|
|
122
|
+
if (automatic && code === 'changed') {
|
|
123
|
+
handled.current = null;
|
|
124
|
+
setState(previous => ({...previous, busy: false}));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
118
127
|
const message = labels[code as keyof typeof labels] || labels.failed;
|
|
119
128
|
setState(previous => ({...previous, error: automatic && previous.error ? previous.error : message, busy: false, diff: null}));
|
|
120
129
|
} finally {
|
|
121
130
|
running.current = false;
|
|
131
|
+
// A result dropped by a newer load still ends the busy state, unless a refresh follows at once.
|
|
132
|
+
if (epoch.current !== ticket && !refreshPending.current && alive.current) {
|
|
133
|
+
setState(previous => ({...previous, busy: false}));
|
|
134
|
+
}
|
|
122
135
|
if (automatic) {background.current = null; settle();}
|
|
123
136
|
if (refreshPending.current) {
|
|
124
137
|
refreshPending.current = false;
|
|
@@ -143,14 +156,26 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
143
156
|
};
|
|
144
157
|
controller.refresh = async (automatic = false) => {
|
|
145
158
|
if (!alive.current || !current.current.repository) return;
|
|
159
|
+
if (!shownRef.current) {
|
|
160
|
+
deferred.current = true;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
146
163
|
if (running.current) {
|
|
147
164
|
if (!automatic) refreshPending.current = true;
|
|
148
165
|
return;
|
|
149
166
|
}
|
|
150
167
|
await run(async () => {
|
|
151
168
|
const {repository, project} = current.current;
|
|
152
|
-
|
|
153
|
-
|
|
169
|
+
// A save or a Git command may still be finishing its writes; read again briefly before reporting a change.
|
|
170
|
+
for (let attempt = 1; ; attempt++) {
|
|
171
|
+
try {
|
|
172
|
+
const snapshot = await repository!.read(project ?? '');
|
|
173
|
+
return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null};
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (!(error instanceof GitPanelError && error.code === 'changed') || attempt >= 5) throw error;
|
|
176
|
+
await new Promise(resolve => setTimeout(resolve, 200 * attempt));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
154
179
|
}, automatic);
|
|
155
180
|
};
|
|
156
181
|
useEffect(() => {
|
|
@@ -159,13 +184,21 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
159
184
|
setDirty(host.isProjectDirty());
|
|
160
185
|
if (event === 'loaded' && !opening.current) {
|
|
161
186
|
epoch.current++;
|
|
187
|
+
// An operation still running stays busy; its end, or the refresh queued after it, clears the flag.
|
|
162
188
|
setState(previous => ({...previous, project: null, snapshot: null, changes: [],
|
|
163
|
-
selected: null, diff: null, commit: null, busy:
|
|
189
|
+
selected: null, diff: null, commit: null, busy: running.current}));
|
|
164
190
|
queueMicrotask(() => void controller.refresh());
|
|
165
191
|
}
|
|
166
192
|
});
|
|
167
193
|
return () => {alive.current = false; epoch.current++; remoteRequest.current?.abort(); unsubscribe?.();};
|
|
168
194
|
}, [host]);
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
shownRef.current = shown;
|
|
197
|
+
if (shown && deferred.current) {
|
|
198
|
+
deferred.current = false;
|
|
199
|
+
void controller.refresh();
|
|
200
|
+
}
|
|
201
|
+
}, [shown]);
|
|
169
202
|
// While the panel is open, notice Git commands, agents and editors that change the repository outside the Editor.
|
|
170
203
|
useEffect(() => {
|
|
171
204
|
const repository = state.repository;
|
|
@@ -175,7 +208,9 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
175
208
|
let checking = false;
|
|
176
209
|
const check = async () => {
|
|
177
210
|
const {snapshot} = current.current;
|
|
178
|
-
|
|
211
|
+
// A tab behind another one has no boxes; it reads again when it is shown.
|
|
212
|
+
const hidden = !dialog.current || !dialog.current.getClientRects().length;
|
|
213
|
+
if (stopped || checking || running.current || document.visibilityState === 'hidden' || hidden ||
|
|
179
214
|
!snapshot || current.current.repository !== repository) return;
|
|
180
215
|
checking = true;
|
|
181
216
|
try {
|
|
@@ -409,7 +444,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
409
444
|
</div>}
|
|
410
445
|
</>;
|
|
411
446
|
// In a tab the Editor stays usable beside the panel, so there is no backdrop, focus trap or Escape to close.
|
|
412
|
-
if (slot) return createPortal(<div className="twgit-panel twgit-panel-tab" role="region" aria-label={labels.title}>{content}</div>,
|
|
447
|
+
if (slot) return createPortal(<div className="twgit-panel twgit-panel-tab" ref={dialog} role="region" aria-label={labels.title}>{content}</div>,
|
|
413
448
|
slot.element);
|
|
414
449
|
return <div className="twgit-backdrop">
|
|
415
450
|
<div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
|
|
@@ -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;
|