@kubohiroya/turbowarp-plus-editor-git 0.7.0 → 0.8.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/README.ja.md +22 -6
- package/README.md +9 -1
- package/dist/block-diff-preview.d.ts +18 -2
- package/dist/file-diff-view.d.ts +3 -1
- package/dist/index.js +11 -4
- package/dist/{libesm-OWRWzmu_.js → project-asset-paths-KV715791.js} +23 -1
- package/dist/project-asset-paths.d.ts +12 -0
- package/dist/project-assets.d.ts +19 -0
- package/dist/project-source-diff.d.ts +5 -1
- package/dist/project-source-diff.js +10 -7
- package/dist/project-source-review-model.d.ts +4 -1
- package/dist/project-source-review.d.ts +6 -2
- package/dist/{repository-DplMQiuo.js → repository-BQkADJmK.js} +71 -3
- package/dist/repository.d.ts +10 -0
- package/dist/repository.js +1 -1
- package/dist/{scratch-block-diff-renderer-CLYoOIB2.js → scratch-block-diff-renderer-xS8OYsRl.js} +453 -87
- package/dist/scratch-block-diff-renderer.d.ts +69 -6
- package/dist/style.css +31 -2
- package/package.json +7 -2
- package/src/block-diff-preview.tsx +89 -8
- package/src/file-diff-view.tsx +4 -3
- package/src/github-pull-requests.ts +73 -6
- package/src/index.tsx +5 -2
- package/src/project-asset-paths.ts +33 -0
- package/src/project-assets.ts +66 -0
- package/src/project-source-diff.css +31 -2
- package/src/project-source-diff.tsx +14 -7
- package/src/project-source-review-model.ts +4 -1
- package/src/project-source-review.tsx +56 -23
- package/src/repository.ts +58 -2
- package/src/scratch-block-diff-renderer.ts +129 -11
|
@@ -3,7 +3,7 @@ import {compareProjectBlocks, readProjectSourceDiff} from './project-source-patc
|
|
|
3
3
|
import type {BlockChange} from './project-source-patch.js';
|
|
4
4
|
import type {ProjectBlockLocator} from './block-diff-location.js';
|
|
5
5
|
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
6
|
-
import {BlockPreview} from './block-diff-preview.js';
|
|
6
|
+
import {BlockPreview, usePreviewSync} from './block-diff-preview.js';
|
|
7
7
|
import type {LocationStatus} from './block-diff-preview.js';
|
|
8
8
|
import './project-source-diff.css';
|
|
9
9
|
|
|
@@ -15,17 +15,22 @@ 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 {currentCostume} from './project-assets.js';
|
|
19
|
+
export type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
18
20
|
export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
|
|
19
|
-
export type {
|
|
21
|
+
export type {BlockPreviewView, BlockPreviewViewport, ProjectBlockRenderer, ProjectBlockRendering, ScratchBlockDiffRendererOptions,
|
|
22
|
+
ScratchBlocksPort} from './scratch-block-diff-renderer.js';
|
|
23
|
+
export {createPreviewSync} from './block-diff-preview.js';
|
|
24
|
+
export type {PreviewSync} from './block-diff-preview.js';
|
|
20
25
|
|
|
21
26
|
const en = {
|
|
22
27
|
title: 'Block changes', before: 'Before', after: 'After', added: 'Added', removed: 'Removed', updated: 'Changed',
|
|
23
28
|
inputs: 'Inputs', fields: 'Fields', opcode: 'Block type', mutation: 'Block shape', position: 'Position',
|
|
24
29
|
connection: 'Connections', properties: 'Other block properties',
|
|
25
30
|
absent: 'No block on this side', omitted: (count: number) => `${count} unchanged blocks omitted from the change list`,
|
|
26
|
-
context: 'Highlight in the surrounding
|
|
31
|
+
context: 'Highlight in the surrounding code', more: 'Show more changes', noChanges: 'No block changes.',
|
|
27
32
|
locate: 'Highlight in Editor', locating: 'Finding the block in the Editor…', located: 'Highlighted in the Editor.',
|
|
28
|
-
missingLocation: 'This version or block is unavailable in the Editor. Open its surrounding
|
|
33
|
+
missingLocation: 'This version or block is unavailable in the Editor. Open its surrounding code here.',
|
|
29
34
|
partLegend: 'Orange outlines mark changed inputs and fields.',
|
|
30
35
|
otherChanges: 'The project also has changes outside its block code.',
|
|
31
36
|
invalid: 'This diff could not be read. Use a full-context project diff, or provide the original project.',
|
|
@@ -36,9 +41,9 @@ const ja: typeof en = {
|
|
|
36
41
|
inputs: '入力値', fields: 'フィールド', opcode: 'ブロックの種類', mutation: 'ブロックの形', position: '位置',
|
|
37
42
|
connection: 'つなぎ方', properties: 'その他のブロック属性',
|
|
38
43
|
absent: 'この側にはブロックがありません', omitted: count => `変更のないブロック ${count} 個を一覧から省略`,
|
|
39
|
-
context: '
|
|
44
|
+
context: '元のコード内で強調', more: '続きを表示', noChanges: 'ブロックの変更はありません。',
|
|
40
45
|
locate: '編集画面で強調', locating: '編集画面でブロックを検索中…', located: '編集画面で強調しました。',
|
|
41
|
-
missingLocation: '
|
|
46
|
+
missingLocation: '編集画面に該当する版またはブロックがありません。元のコードをここで展開できます。',
|
|
42
47
|
partLegend: 'オレンジの枠は、変更された入力欄やフィールドです。',
|
|
43
48
|
otherChanges: 'ブロックのコード以外にも作品の変更があります。',
|
|
44
49
|
invalid: '差分を読み込めません。全体の文脈を含む差分を使うか、変更前の作品を指定してください。',
|
|
@@ -66,6 +71,7 @@ function ChangeCard({change, renderBlock, labels, onLocate, location}: {
|
|
|
66
71
|
onLocate?: (version: Version) => void; location?: {version: Version; status: LocationStatus};
|
|
67
72
|
}) {
|
|
68
73
|
const [withContext, setWithContext] = useState(false);
|
|
74
|
+
const {sync, toggle} = usePreviewSync(labels === en ? 'en' : 'ja');
|
|
69
75
|
const notes = [...new Set(change.fields.map(field => labels[propertyLabels.get(field) ?? 'properties']))];
|
|
70
76
|
const name = (change.after ?? change.before)!.target.name;
|
|
71
77
|
return <article className={`twgit-block-diff-card twgit-block-diff-${change.kind}`}>
|
|
@@ -77,9 +83,10 @@ function ChangeCard({change, renderBlock, labels, onLocate, location}: {
|
|
|
77
83
|
<div className="twgit-block-diff-sides">
|
|
78
84
|
{(['before', 'after'] as const).map(version => <BlockPreview key={version} side={change[version]} title={labels[version]}
|
|
79
85
|
renderBlock={renderBlock} withContext={withContext} labels={labels} parts={change.parts}
|
|
80
|
-
onLocate={onLocate && (() => onLocate(version))}
|
|
86
|
+
onLocate={onLocate && (() => onLocate(version))} version={version} sync={sync}
|
|
81
87
|
locationStatus={location?.version === version ? location.status : undefined}/>)}
|
|
82
88
|
</div>
|
|
89
|
+
{toggle}
|
|
83
90
|
<label><input type="checkbox" checked={withContext} onChange={event => setWithContext(event.currentTarget.checked)}/> {labels.context}</label>
|
|
84
91
|
</article>;
|
|
85
92
|
}
|
|
@@ -11,6 +11,9 @@ export interface ScriptReview {
|
|
|
11
11
|
export interface TargetReview {
|
|
12
12
|
name: string;
|
|
13
13
|
isStage: boolean;
|
|
14
|
+
/** The stage or sprite in each version, absent where it does not exist. */
|
|
15
|
+
before?: ProjectTarget;
|
|
16
|
+
after?: ProjectTarget;
|
|
14
17
|
scripts: ScriptReview[];
|
|
15
18
|
properties: string[];
|
|
16
19
|
}
|
|
@@ -106,7 +109,7 @@ export function compareProjectForReview(before: ProjectSource, after: ProjectSou
|
|
|
106
109
|
const properties = changedProperties({...a}, {...b}, 'blocks');
|
|
107
110
|
if (scripts.length || properties.length) {
|
|
108
111
|
const {name, isStage} = (b ?? a)!;
|
|
109
|
-
targets.push({name, isStage, scripts, properties});
|
|
112
|
+
targets.push({name, isStage, scripts, properties, before: a, after: b});
|
|
110
113
|
}
|
|
111
114
|
}
|
|
112
115
|
return {targets, properties: changedProperties(before, after, 'targets')};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import React, {useMemo, useState} from 'react';
|
|
2
|
-
import {BlockPreview} from './block-diff-preview.js';
|
|
2
|
+
import {BlockPreview, usePreviewSync} from './block-diff-preview.js';
|
|
3
3
|
import {isRecord, parseProjectSource, readProjectSourceDiff} from './project-source-patch.js';
|
|
4
4
|
import type {BlockSide} from './project-source-patch.js';
|
|
5
5
|
import {compareProjectForReview} from './project-source-review-model.js';
|
|
6
|
-
import type {ScriptReview} from './project-source-review-model.js';
|
|
6
|
+
import type {ScriptReview, TargetReview} from './project-source-review-model.js';
|
|
7
7
|
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
8
|
+
import {currentCostume, useCostumeImage} from './project-assets.js';
|
|
9
|
+
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
8
10
|
|
|
9
11
|
type Comparison = ReturnType<typeof compareProjectForReview>;
|
|
10
12
|
|
|
@@ -13,7 +15,7 @@ export type ProjectSourceReviewFile = {
|
|
|
13
15
|
summary?: string;
|
|
14
16
|
previousPath?: string;
|
|
15
17
|
unavailable?: boolean;
|
|
16
|
-
} & ({patch: string; beforeSource?: string} | {status: 'added' | 'removed' | 'updated'});
|
|
18
|
+
} & ({patch: string; beforeSource?: string; loadAsset?: ProjectAssetLoader} | {status: 'added' | 'removed' | 'updated'});
|
|
17
19
|
|
|
18
20
|
export interface ProjectSourceReviewProps {
|
|
19
21
|
title: string;
|
|
@@ -24,26 +26,29 @@ export interface ProjectSourceReviewProps {
|
|
|
24
26
|
locale?: string;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
// Like the Editor's Code tab, a connected group of blocks is called code, and each item is named by its hat block.
|
|
27
30
|
const ja = {
|
|
28
31
|
review: '変更レビュー', fullDescription: 'PR 本文を表示', checks: '確認してほしいこと', files: '変更ファイル', targets: '影響するステージ・スプライト',
|
|
29
|
-
scripts: '
|
|
32
|
+
scripts: 'コードの変更', scriptCount: (count: number) => `コードの変更 · ${count} か所`,
|
|
33
|
+
reviewed: '確認済み', before: '変更前', after: '変更後',
|
|
30
34
|
updated: '変更', added: '追加', removed: '削除', reorganized: '再編',
|
|
31
|
-
absent: '
|
|
32
|
-
unavailable: '
|
|
35
|
+
absent: 'この版にはこのコードがありません', loading: 'コードを描画中…',
|
|
36
|
+
unavailable: 'このコードを描画できません。',
|
|
33
37
|
related: '関連ファイル', properties: 'コード以外の変更', invalid: 'この作品の差分を読み込めません。',
|
|
34
38
|
flag: '緑の旗が押されたとき', broadcast: 'を受け取ったとき', key: 'キーが押されたとき',
|
|
35
|
-
click: 'このスプライトが押されたとき', script: '
|
|
39
|
+
click: 'このスプライトが押されたとき', script: 'コード', noChanges: '作品に内容の変更はありません。',
|
|
36
40
|
comparison: '作品の変更', unreadable: '作品データとして読み込めないため、テキストの差分を表示します。'
|
|
37
41
|
};
|
|
38
42
|
const en: typeof ja = {
|
|
39
43
|
review: 'Change review', fullDescription: 'Show full PR description', checks: 'Review checklist', files: 'Changed files', targets: 'Affected stages and sprites',
|
|
40
|
-
scripts: '
|
|
44
|
+
scripts: 'Code changes', scriptCount: count => `Code changes · ${count}`,
|
|
45
|
+
reviewed: 'Reviewed', before: 'Before', after: 'After',
|
|
41
46
|
updated: 'Changed', added: 'Added', removed: 'Removed', reorganized: 'Reorganized',
|
|
42
|
-
absent: 'No
|
|
43
|
-
unavailable: 'This
|
|
47
|
+
absent: 'No code in this version', loading: 'Rendering code…',
|
|
48
|
+
unavailable: 'This code could not be rendered.',
|
|
44
49
|
related: 'Related file', properties: 'Changes outside the code', invalid: 'This project diff could not be read.',
|
|
45
50
|
flag: 'When green flag clicked', broadcast: 'received', key: 'When key pressed',
|
|
46
|
-
click: 'When this sprite clicked', script: '
|
|
51
|
+
click: 'When this sprite clicked', script: 'Code', noChanges: 'No changes to the project contents.',
|
|
47
52
|
comparison: 'Project changes', unreadable: 'This file could not be read as project data, so its text diff is shown.'
|
|
48
53
|
};
|
|
49
54
|
|
|
@@ -69,11 +74,12 @@ function scriptTitle(side: BlockSide, labels: typeof ja) {
|
|
|
69
74
|
return typeof block.opcode === 'string' ? block.opcode : labels.script;
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock}: {
|
|
77
|
+
function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset}: {
|
|
73
78
|
script: ScriptReview; labels: typeof ja; reviewed?: boolean; setReviewed?(value: boolean): void;
|
|
74
|
-
renderBlock: ProjectBlockRenderer;
|
|
79
|
+
renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
|
|
75
80
|
}) {
|
|
76
81
|
const [open, setOpen] = useState(false);
|
|
82
|
+
const {sync, toggle} = usePreviewSync(labels === ja ? 'ja' : 'en');
|
|
77
83
|
const status = !script.before.length ? 'added' : !script.after.length ? 'removed' :
|
|
78
84
|
script.before.length !== 1 || script.after.length !== 1 ? 'reorganized' : 'updated';
|
|
79
85
|
const beforeTitle = script.before[0] && scriptTitle(script.before[0], labels);
|
|
@@ -91,10 +97,12 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
|
|
|
91
97
|
{(['before', 'after'] as const).map(version => <div key={version}>
|
|
92
98
|
{script[version].length ? script[version].map((side, index, sides) => <BlockPreview key={side.id} side={side}
|
|
93
99
|
title={sides.length > 1 ? `${labels[version]} ${index + 1}` : labels[version]}
|
|
94
|
-
marks={side.marks} renderBlock={renderBlock} withContext labels={labels}
|
|
100
|
+
marks={side.marks} renderBlock={renderBlock} withContext labels={labels}
|
|
101
|
+
version={version} loadAsset={loadAsset} sync={sync}/>) :
|
|
95
102
|
<BlockPreview title={labels[version]} renderBlock={renderBlock} withContext labels={labels}/>}
|
|
96
103
|
</div>)}
|
|
97
104
|
</div>
|
|
105
|
+
{toggle}
|
|
98
106
|
{setReviewed && <label className="twgit-review-checked"><input type="checkbox" checked={reviewed}
|
|
99
107
|
onChange={event => setReviewed(event.currentTarget.checked)}/> {labels.reviewed}</label>}
|
|
100
108
|
</>}
|
|
@@ -105,20 +113,42 @@ function propertyNames(keys: readonly string[], labels: typeof ja) {
|
|
|
105
113
|
return keys.map(key => properties.get(key)?.[labels === ja ? 0 : 1] ?? key).join(' · ');
|
|
106
114
|
}
|
|
107
115
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
/**
|
|
117
|
+
* The current costume (the backdrop for the stage) of each version of a target that has one. A changed costume shows
|
|
118
|
+
* before → after. Nothing is shown until an image is read, so the heading falls back to the name alone.
|
|
119
|
+
*/
|
|
120
|
+
function TargetThumbnails({target, labels, loadAsset}: {target: TargetReview; labels: typeof ja; loadAsset?: ProjectAssetLoader}) {
|
|
121
|
+
const images = {before: useCostumeImage(target.before, 'before', loadAsset), after: useCostumeImage(target.after, 'after', loadAsset)};
|
|
122
|
+
const costumes = {before: target.before && currentCostume(target.before), after: target.after && currentCostume(target.after)};
|
|
123
|
+
const changed = !!costumes.before && !!costumes.after && costumes.before.md5ext !== costumes.after.md5ext;
|
|
124
|
+
// An unchanged costume is the same file in both versions, so either read shows it.
|
|
125
|
+
const versions: ProjectVersion[] = changed ? ['before', 'after'] : [images.after ? 'after' : 'before'];
|
|
126
|
+
const shown = versions.filter(version => images[version]);
|
|
127
|
+
if (!shown.length) return null;
|
|
128
|
+
return <span className="twgit-review-thumbnails">
|
|
129
|
+
{shown.map((version, index) => <React.Fragment key={version}>
|
|
130
|
+
{index > 0 && <span aria-hidden="true">→</span>}
|
|
131
|
+
<img src={images[version]!} title={costumes[version]?.name || undefined}
|
|
132
|
+
alt={changed ? `${labels[version]}: ${costumes[version]?.name ?? ''}` : ''}/>
|
|
133
|
+
</React.Fragment>)}
|
|
134
|
+
</span>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Lists the changed stages and sprites of one project file, each with its code comparisons. */
|
|
138
|
+
function ComparisonTargets({comparison, labels, renderBlock, loadAsset, review}: {
|
|
139
|
+
comparison: Comparison; labels: typeof ja; renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
|
|
111
140
|
review?: {file: string; keys: ReadonlySet<string>; set(key: string, value: boolean): void};
|
|
112
141
|
}) {
|
|
113
142
|
return <>
|
|
114
143
|
{comparison.properties.length > 0 && <p>{labels.properties} · {propertyNames(comparison.properties, labels)}</p>}
|
|
115
144
|
{!comparison.targets.length && !comparison.properties.length && <p>{labels.noChanges}</p>}
|
|
116
145
|
{comparison.targets.map(target => <section className="twgit-review-target" key={JSON.stringify([target.isStage, target.name])}>
|
|
117
|
-
<h5
|
|
146
|
+
<h5><TargetThumbnails target={target} labels={labels} loadAsset={loadAsset}/>
|
|
147
|
+
{target.name}{target.scripts.length > 0 && <small>{labels.scriptCount(target.scripts.length)}</small>}</h5>
|
|
118
148
|
{target.properties.length > 0 && <p>{labels.properties} · {propertyNames(target.properties, labels)}</p>}
|
|
119
149
|
{target.scripts.map(script => {
|
|
120
150
|
const key = JSON.stringify([review?.file, target.isStage, target.name, script.id]);
|
|
121
|
-
return <ScriptComparison key={key} script={script} labels={labels} renderBlock={renderBlock}
|
|
151
|
+
return <ScriptComparison key={key} script={script} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
|
|
122
152
|
reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}/>;
|
|
123
153
|
})}
|
|
124
154
|
</section>)}
|
|
@@ -136,10 +166,12 @@ export interface ProjectSourceComparisonProps {
|
|
|
136
166
|
locale?: string;
|
|
137
167
|
/** Shown after an explanation when either version cannot be read as a project. Without it, only an error is shown. */
|
|
138
168
|
fallback?: React.ReactNode;
|
|
169
|
+
/** Reads the images of both versions for thumbnails. Without it, stages and sprites are shown by name. */
|
|
170
|
+
loadAsset?: ProjectAssetLoader;
|
|
139
171
|
}
|
|
140
172
|
|
|
141
|
-
/** Compares two versions of a project.source.json by stage, sprite and
|
|
142
|
-
export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback}: ProjectSourceComparisonProps) {
|
|
173
|
+
/** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
|
|
174
|
+
export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset}: ProjectSourceComparisonProps) {
|
|
143
175
|
const labels = locale.startsWith('ja') ? ja : en;
|
|
144
176
|
const comparison = useMemo(() => {
|
|
145
177
|
try {
|
|
@@ -157,7 +189,7 @@ export function ProjectSourceComparison({before, after, renderBlock, locale = 'e
|
|
|
157
189
|
<div><dt>{labels.scripts}</dt><dd>{countScripts(comparison)}</dd></div>
|
|
158
190
|
</dl>
|
|
159
191
|
</header>
|
|
160
|
-
<ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}/>
|
|
192
|
+
<ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}/>
|
|
161
193
|
</section>;
|
|
162
194
|
}
|
|
163
195
|
|
|
@@ -194,7 +226,7 @@ export function ProjectSourceReview({title, description, verification, files, re
|
|
|
194
226
|
<div><dt>{labels.files}</dt><dd>{entries.length}</dd></div>
|
|
195
227
|
<div><dt>{labels.targets}</dt><dd>{targets}</dd></div>
|
|
196
228
|
<div><dt>{labels.scripts}</dt><dd>{scripts}</dd></div>
|
|
197
|
-
{scripts > 0 && <div><dt>{labels.
|
|
229
|
+
{scripts > 0 && <div><dt>{labels.reviewed}</dt><dd>{reviewed.size} / {scripts}</dd></div>}
|
|
198
230
|
</dl>
|
|
199
231
|
{longBody && <details className="twgit-review-body">
|
|
200
232
|
<summary>{labels.fullDescription}</summary>
|
|
@@ -211,6 +243,7 @@ export function ProjectSourceReview({title, description, verification, files, re
|
|
|
211
243
|
{'status' in file && <p>{labels.related} · {labels[file.status]}</p>}
|
|
212
244
|
{(invalid || file.unavailable) && <p role="alert">{labels.invalid}</p>}
|
|
213
245
|
{comparison && <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}
|
|
246
|
+
loadAsset={'loadAsset' in file ? file.loadAsset : undefined}
|
|
214
247
|
review={{file: file.path, keys: reviewed, set: setReviewed}}/>}
|
|
215
248
|
</section>)}
|
|
216
249
|
</section>;
|
package/src/repository.ts
CHANGED
|
@@ -3,6 +3,8 @@ import {createTwoFilesPatch} from 'diff';
|
|
|
3
3
|
import {createReadOnlyFs, errorCode, GitPanelError, pathParts, readOptional, sameBytes} from './filesystem.js';
|
|
4
4
|
import {runRemoteOperation} from './remote-operations.js';
|
|
5
5
|
import type {GetRemoteSession, RemoteOperation} from './remote-operations.js';
|
|
6
|
+
import {assetLimit, assetsDirectoryParts, folderSettingsLimit, isAssetName} from './project-asset-paths.js';
|
|
7
|
+
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
6
8
|
|
|
7
9
|
export {createReadOnlyFs, GitPanelError} from './filesystem.js';
|
|
8
10
|
export type ChangeGroup = 'staged' | 'unstaged' | 'untracked' | 'commit';
|
|
@@ -14,6 +16,8 @@ export interface FileChange {
|
|
|
14
16
|
worktree: boolean;
|
|
15
17
|
oldMode: number | null;
|
|
16
18
|
newMode: number | null;
|
|
19
|
+
/** For `commit` changes, the commit compared with its first parent. */
|
|
20
|
+
commit?: string;
|
|
17
21
|
}
|
|
18
22
|
export interface Commit {
|
|
19
23
|
oid: string;
|
|
@@ -22,6 +26,8 @@ export interface Commit {
|
|
|
22
26
|
timestamp: number;
|
|
23
27
|
}
|
|
24
28
|
interface Entry {oid: string; mode: number}
|
|
29
|
+
/** Where one version of a file is read from: a commit or tree, the index, or the working tree. */
|
|
30
|
+
type Source = {kind: 'tree'; ref: string} | {kind: 'index'} | {kind: 'worktree'};
|
|
25
31
|
type Metadata = Map<string, Uint8Array | null>;
|
|
26
32
|
export interface Snapshot {
|
|
27
33
|
branch: string | undefined;
|
|
@@ -211,7 +217,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
211
217
|
const a = before.get(path), b = after.get(path);
|
|
212
218
|
if (a?.oid !== b?.oid || a?.mode !== b?.mode) result.push({
|
|
213
219
|
path, group: 'commit', before: a?.oid ?? null, after: b?.oid ?? null,
|
|
214
|
-
oldMode: a?.mode ?? null, newMode: b?.mode ?? null, worktree: false
|
|
220
|
+
oldMode: a?.mode ?? null, newMode: b?.mode ?? null, worktree: false, commit: oid
|
|
215
221
|
});
|
|
216
222
|
}
|
|
217
223
|
await assertSnapshot(snapshot);
|
|
@@ -256,6 +262,47 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
256
262
|
return patch === undefined ? {...base, tooLarge: true, patch: '', text} :
|
|
257
263
|
{...base, patch: 'diff --git a/file b/file\n' + patch.replace(/^=+\n/, ''), text};
|
|
258
264
|
};
|
|
265
|
+
const sourceOf = async (snapshot: Snapshot, change: FileChange, version: ProjectVersion): Promise<Source | null> => {
|
|
266
|
+
if (change[version] === null) return null;
|
|
267
|
+
switch (change.group) {
|
|
268
|
+
case 'staged': return version === 'after' ? {kind: 'index'} : snapshot.head ? {kind: 'tree', ref: snapshot.head} : null;
|
|
269
|
+
case 'unstaged': return version === 'after' ? {kind: 'worktree'} : {kind: 'index'};
|
|
270
|
+
case 'untracked': return version === 'after' ? {kind: 'worktree'} : null;
|
|
271
|
+
case 'commit': {
|
|
272
|
+
if (!change.commit) return null;
|
|
273
|
+
if (version === 'after') return {kind: 'tree', ref: change.commit};
|
|
274
|
+
const parent = (await git.readCommit({...options, cache, oid: change.commit})).commit.parent[0];
|
|
275
|
+
return parent ? {kind: 'tree', ref: parent} : null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
/** Reads one regular file of one version, or `null` when it is missing or larger than the limit. */
|
|
280
|
+
const readVersion = async (source: Source, path: string, limit: number): Promise<Uint8Array | null> => {
|
|
281
|
+
if (source.kind === 'worktree') {
|
|
282
|
+
const stat = await fs.promises.stat('/' + path).catch(() => null);
|
|
283
|
+
if (!stat?.isFile() || stat.size > limit) return null;
|
|
284
|
+
const bytes = await readOptional(fs, '/' + path);
|
|
285
|
+
return bytes && bytes.byteLength <= limit ? bytes : null;
|
|
286
|
+
}
|
|
287
|
+
const entry = (await entries(source.kind === 'tree' ? source.ref : null, path)).get(path);
|
|
288
|
+
if (!entry || (entry.mode !== 0o100644 && entry.mode !== 0o100755)) return null;
|
|
289
|
+
const {blob} = await git.readBlob({...options, cache, oid: entry.oid});
|
|
290
|
+
return blob.byteLength <= limit ? blob : null;
|
|
291
|
+
};
|
|
292
|
+
const readAsset = async (snapshot: Snapshot, change: FileChange, version: ProjectVersion, md5ext: string, signal: AbortSignal) => {
|
|
293
|
+
if (signal.aborted) return null;
|
|
294
|
+
fs.clearReads();
|
|
295
|
+
await assertSnapshot(snapshot);
|
|
296
|
+
const source = await sourceOf(snapshot, change, version);
|
|
297
|
+
if (!source) return null;
|
|
298
|
+
const folder = pathParts(change.path).slice(0, -1);
|
|
299
|
+
const settings = await readVersion(source, [...folder, 'sb3-source.json'].join('/'), folderSettingsLimit);
|
|
300
|
+
const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
|
|
301
|
+
if (!directory || signal.aborted) return null;
|
|
302
|
+
const bytes = await readVersion(source, [...folder, ...directory, md5ext].join('/'), assetLimit);
|
|
303
|
+
await fs.checkReads();
|
|
304
|
+
return bytes && !signal.aborted ? new Blob([bytes as Uint8Array<ArrayBuffer>]) : null;
|
|
305
|
+
};
|
|
259
306
|
const directoryAt = async (path: string) => {
|
|
260
307
|
let directory = root;
|
|
261
308
|
for (const part of pathParts(path)) directory = await directory.getDirectoryHandle(part);
|
|
@@ -302,7 +349,16 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
302
349
|
}),
|
|
303
350
|
read: (scope?: string) => serial(() => read(scope)),
|
|
304
351
|
readDiff: (snapshot: Snapshot, change: FileChange) => serial(() => readDiff(snapshot, change)),
|
|
305
|
-
historyChanges: (snapshot: Snapshot, oid: string) => serial(() => historyChanges(snapshot, oid))
|
|
352
|
+
historyChanges: (snapshot: Snapshot, oid: string) => serial(() => historyChanges(snapshot, oid)),
|
|
353
|
+
/**
|
|
354
|
+
* Reads the assets of both versions of a selected project file, for thumbnails. Staged changes compare HEAD with
|
|
355
|
+
* the index, unstaged ones the index with the working tree, untracked ones nothing with the working tree, and
|
|
356
|
+
* commit changes the first parent with the commit. Each version's sb3-source.json names its assets directory.
|
|
357
|
+
* Reads never write, and resolve `null` for anything missing, invalid, larger than 10 MiB or changed since the snapshot.
|
|
358
|
+
*/
|
|
359
|
+
assetLoader: (snapshot: Snapshot, change: FileChange): ProjectAssetLoader => ({version, md5ext, signal}) =>
|
|
360
|
+
!isAssetName(md5ext) || signal.aborted ? Promise.resolve(null) :
|
|
361
|
+
serial(() => readAsset(snapshot, change, version, md5ext, signal)).catch(() => null)
|
|
306
362
|
};
|
|
307
363
|
}
|
|
308
364
|
export type Repository = Awaited<ReturnType<typeof openRepository>>;
|
|
@@ -6,12 +6,25 @@ import type {DiffSvgBlock} from './block-diff-location.js';
|
|
|
6
6
|
interface BlockWorkspace {
|
|
7
7
|
dispose(): void;
|
|
8
8
|
markFocused(): void;
|
|
9
|
-
getBlocksBoundingBox(): {width: number; height: number};
|
|
9
|
+
getBlocksBoundingBox(): {x?: number; y?: number; width: number; height: number};
|
|
10
10
|
getBlockById(id: string): DiffSvgBlock | null;
|
|
11
|
-
options?: {pathToMedia?: string};
|
|
11
|
+
options?: {pathToMedia?: string; zoomOptions?: {startScale?: number}};
|
|
12
|
+
scale?: number;
|
|
13
|
+
scrollX?: number;
|
|
14
|
+
scrollY?: number;
|
|
15
|
+
translate?(x: number, y: number): void;
|
|
16
|
+
getMetrics?(): {absoluteLeft?: number; absoluteTop?: number} | null;
|
|
17
|
+
scrollbar?: {resize(): void} | null;
|
|
18
|
+
}
|
|
19
|
+
interface InjectOptions {
|
|
20
|
+
readOnly: true;
|
|
21
|
+
sounds: false;
|
|
22
|
+
media?: string;
|
|
23
|
+
scrollbars?: boolean;
|
|
24
|
+
zoom?: {controls: boolean; wheel: boolean; startScale: number; maxScale: number; minScale: number; scaleSpeed: number};
|
|
12
25
|
}
|
|
13
26
|
export interface ScratchBlocksPort {
|
|
14
|
-
inject(container: HTMLElement, options:
|
|
27
|
+
inject(container: HTMLElement, options: InjectOptions): BlockWorkspace;
|
|
15
28
|
svgResize(workspace: BlockWorkspace): void;
|
|
16
29
|
getMainWorkspace(): BlockWorkspace | null;
|
|
17
30
|
mainWorkspace: BlockWorkspace | null;
|
|
@@ -30,6 +43,12 @@ export interface ScratchBlockDiffRendererOptions {
|
|
|
30
43
|
createBlock(block: {id: string}, deferProjectChanged: true): void;
|
|
31
44
|
blockToXML(id: string): string | undefined;
|
|
32
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* Adds the host's workspace navigation to each preview, as in its own code area: for example the costume
|
|
48
|
+
* thumbnail, a minimap and a row of zoom buttons in the lower right. `watermark` holds the costume thumbnail when
|
|
49
|
+
* the version has one; the host positions it. Without this option, previews keep Scratch Blocks' zoom buttons.
|
|
50
|
+
*/
|
|
51
|
+
attachNavigation?(workspace: unknown, blockly: ScratchBlocksPort, watermark?: HTMLElement): {dispose(): void} | null | undefined;
|
|
33
52
|
}
|
|
34
53
|
export interface ScriptBlockMark {
|
|
35
54
|
id: string;
|
|
@@ -37,9 +56,25 @@ export interface ScriptBlockMark {
|
|
|
37
56
|
parts: readonly BlockPart[];
|
|
38
57
|
}
|
|
39
58
|
|
|
59
|
+
/** Position and zoom of one preview, in Scratch Blocks workspace terms. */
|
|
60
|
+
export interface BlockPreviewViewport {scale: number; x: number; y: number}
|
|
61
|
+
/** Lets several previews of one comparison share their zoom, scroll position and height. */
|
|
62
|
+
export interface BlockPreviewView {
|
|
63
|
+
/** The zoom and frame height at which this preview shows all of its blocks. */
|
|
64
|
+
readonly fit: {scale: number; height: number};
|
|
65
|
+
/** Applies a zoom and frame height, scrolling to the start of the blocks. */
|
|
66
|
+
layout(fit: {scale: number; height: number}): void;
|
|
67
|
+
viewport(): BlockPreviewViewport;
|
|
68
|
+
setViewport(viewport: BlockPreviewViewport): void;
|
|
69
|
+
/** Calls `listener` after the user scrolls or zooms this preview. */
|
|
70
|
+
onChange(listener: () => void): () => void;
|
|
71
|
+
}
|
|
72
|
+
export type ProjectBlockRendering = (() => void) | {dispose(): void; view?: BlockPreviewView} | undefined;
|
|
40
73
|
export type ProjectBlockRenderer = (args: BlockSide & {
|
|
41
74
|
container: HTMLElement; withContext: boolean; parts?: readonly BlockPart[]; marks?: readonly ScriptBlockMark[]; signal: AbortSignal;
|
|
42
|
-
|
|
75
|
+
/** The costume thumbnail element of this version, placed in the preview's lower right by the host navigation. */
|
|
76
|
+
watermark?: HTMLElement;
|
|
77
|
+
}) => Promise<ProjectBlockRendering>;
|
|
43
78
|
|
|
44
79
|
function scriptRoot({target, id}: BlockSide): string {
|
|
45
80
|
const visited = new Set<string>();
|
|
@@ -136,9 +171,81 @@ function placeholderDefinition(element: Element, opcode: string, type: string, b
|
|
|
136
171
|
return definition;
|
|
137
172
|
}
|
|
138
173
|
|
|
174
|
+
// Scratch Blocks' scale for the code area, which keeps the blocks the size people edit them at.
|
|
175
|
+
const defaultScale = 0.675;
|
|
176
|
+
const padding = 16;
|
|
177
|
+
const minHeight = 160;
|
|
178
|
+
const maxHeight = 420;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Sizes a preview frame to its blocks and reports scrolling and zooming, so both versions can move together.
|
|
182
|
+
* The frame keeps a gutter on the right for the zoom buttons, and for the minimap and thumbnail when present.
|
|
183
|
+
*/
|
|
184
|
+
function createView(workspace: BlockWorkspace, blockly: ScratchBlocksPort, surface: HTMLElement, frame: HTMLElement,
|
|
185
|
+
startScale: number, navigation: boolean) {
|
|
186
|
+
const bounds = workspace.getBlocksBoundingBox();
|
|
187
|
+
const left = bounds.x ?? 0, top = bounds.y ?? 0;
|
|
188
|
+
const gutter = navigation ? 150 : 56;
|
|
189
|
+
const width = frame.clientWidth || 400;
|
|
190
|
+
const scale = Math.max(0.25, Math.min(startScale, (width - padding - gutter) / Math.max(1, bounds.width),
|
|
191
|
+
(maxHeight - 2 * padding) / Math.max(1, bounds.height)));
|
|
192
|
+
const fit = {scale, height: Math.min(maxHeight, Math.max(minHeight, Math.ceil(bounds.height * scale + 2 * padding)))};
|
|
193
|
+
const listeners = new Set<() => void>();
|
|
194
|
+
const translate = workspace.translate?.bind(workspace);
|
|
195
|
+
let quiet = false;
|
|
196
|
+
// Scrolling, dragging and every zoom end in translate, which moves the canvas.
|
|
197
|
+
if (translate) workspace.translate = (x, y) => {
|
|
198
|
+
translate(x, y);
|
|
199
|
+
if (!quiet) for (const listener of listeners) listener();
|
|
200
|
+
};
|
|
201
|
+
const setViewport = ({scale, x, y}: BlockPreviewViewport) => {
|
|
202
|
+
if (!translate) return;
|
|
203
|
+
quiet = true;
|
|
204
|
+
try {
|
|
205
|
+
// setScale would also close menus in the Editor's own workspace.
|
|
206
|
+
workspace.scale = scale;
|
|
207
|
+
workspace.scrollX = x;
|
|
208
|
+
workspace.scrollY = y;
|
|
209
|
+
const metrics = workspace.getMetrics?.();
|
|
210
|
+
translate(x + (metrics?.absoluteLeft ?? 0), y + (metrics?.absoluteTop ?? 0));
|
|
211
|
+
workspace.scrollbar?.resize();
|
|
212
|
+
} finally {
|
|
213
|
+
quiet = false;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const layout = ({scale, height}: {scale: number; height: number}) => {
|
|
217
|
+
// The frame takes the height, so people can still drag its resize handle afterwards.
|
|
218
|
+
frame.style.height = height + 'px';
|
|
219
|
+
blockly.svgResize(workspace);
|
|
220
|
+
setViewport({scale, x: padding - left * scale, y: padding - top * scale});
|
|
221
|
+
};
|
|
222
|
+
layout(fit);
|
|
223
|
+
// Keep the drawing area matched to the frame when the panel or the frame is resized.
|
|
224
|
+
const resize = new (surface.ownerDocument.defaultView ?? globalThis).ResizeObserver(() => blockly.svgResize(workspace));
|
|
225
|
+
resize.observe(surface);
|
|
226
|
+
// A plain wheel scrolls the review; Ctrl and trackpad pinches still zoom the preview.
|
|
227
|
+
const wheel = (event: WheelEvent) => {if (!event.ctrlKey) event.stopPropagation();};
|
|
228
|
+
surface.addEventListener('wheel', wheel, {capture: true});
|
|
229
|
+
const api: BlockPreviewView = {
|
|
230
|
+
fit, layout, setViewport,
|
|
231
|
+
viewport: () => ({scale: workspace.scale ?? fit.scale, x: workspace.scrollX ?? 0, y: workspace.scrollY ?? 0}),
|
|
232
|
+
onChange(listener) {
|
|
233
|
+
listeners.add(listener);
|
|
234
|
+
return () => listeners.delete(listener);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
return {api, dispose() {
|
|
238
|
+
frame.style.removeProperty('height');
|
|
239
|
+
listeners.clear();
|
|
240
|
+
resize.disconnect();
|
|
241
|
+
surface.removeEventListener('wheel', wheel, {capture: true});
|
|
242
|
+
if (translate) workspace.translate = translate;
|
|
243
|
+
}};
|
|
244
|
+
}
|
|
245
|
+
|
|
139
246
|
/** Use the host's Scratch VM serializer and Scratch Blocks definitions in an isolated workspace. */
|
|
140
247
|
export function createScratchBlocksDiffRenderer(options: ScratchBlockDiffRendererOptions): ProjectBlockRenderer {
|
|
141
|
-
return async ({container, target, id, withContext, parts = [], marks, signal}) => {
|
|
248
|
+
return async ({container, target, id, withContext, parts = [], marks, signal, watermark}) => {
|
|
142
249
|
const blockly = await options.getBlockly();
|
|
143
250
|
if (signal.aborted) return;
|
|
144
251
|
const root = withContext ? scriptRoot({target, id}) : id;
|
|
@@ -157,11 +264,16 @@ export function createScratchBlocksDiffRenderer(options: ScratchBlockDiffRendere
|
|
|
157
264
|
surface.className = 'twgit-block-diff-surface';
|
|
158
265
|
container.append(surface);
|
|
159
266
|
let workspace: BlockWorkspace | undefined;
|
|
267
|
+
let view!: ReturnType<typeof createView>;
|
|
268
|
+
let navigation: {dispose(): void} | null | undefined;
|
|
269
|
+
const cleanups: (() => void)[] = [];
|
|
160
270
|
const placeholders: string[] = [];
|
|
161
271
|
const dispose = () => {
|
|
162
272
|
const active = blockly.getMainWorkspace();
|
|
273
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
163
274
|
blockly.Events.disable();
|
|
164
275
|
try {
|
|
276
|
+
navigation?.dispose();
|
|
165
277
|
workspace?.dispose();
|
|
166
278
|
} finally {
|
|
167
279
|
if (active === workspace) blockly.mainWorkspace = main;
|
|
@@ -183,7 +295,9 @@ export function createScratchBlocksDiffRenderer(options: ScratchBlockDiffRendere
|
|
|
183
295
|
options.locale ?? container.ownerDocument.documentElement.lang)]);
|
|
184
296
|
element.setAttribute('type', alias);
|
|
185
297
|
}
|
|
186
|
-
|
|
298
|
+
const startScale = main?.options?.zoomOptions?.startScale ?? defaultScale;
|
|
299
|
+
workspace = blockly.inject(surface, {readOnly: true, sounds: false, media: main?.options?.pathToMedia, scrollbars: true,
|
|
300
|
+
zoom: {controls: true, wheel: true, startScale, maxScale: 4, minScale: 0.25, scaleSpeed: 1.2}});
|
|
187
301
|
workspace.markFocused = () => {};
|
|
188
302
|
blockly.Xml.domToWorkspace(dom, workspace);
|
|
189
303
|
if (marks) {
|
|
@@ -197,10 +311,14 @@ export function createScratchBlocksDiffRenderer(options: ScratchBlockDiffRendere
|
|
|
197
311
|
const changed = workspace.getBlockById(id === root ? serializedRoot : id);
|
|
198
312
|
if (changed) markBlockLocation(changed, parts);
|
|
199
313
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
314
|
+
view = createView(workspace, blockly, surface, container, startScale, !!options.attachNavigation);
|
|
315
|
+
cleanups.push(view.dispose);
|
|
316
|
+
navigation = options.attachNavigation?.(workspace, blockly, watermark);
|
|
317
|
+
if (navigation && watermark) {
|
|
318
|
+
// The host now positions the thumbnail beside its minimap.
|
|
319
|
+
watermark.dataset.navigation = '';
|
|
320
|
+
cleanups.push(() => delete watermark.dataset.navigation);
|
|
321
|
+
}
|
|
204
322
|
} catch (error) {
|
|
205
323
|
dispose();
|
|
206
324
|
throw error;
|
|
@@ -208,6 +326,6 @@ export function createScratchBlocksDiffRenderer(options: ScratchBlockDiffRendere
|
|
|
208
326
|
blockly.mainWorkspace = main;
|
|
209
327
|
blockly.Events.enable();
|
|
210
328
|
}
|
|
211
|
-
return dispose;
|
|
329
|
+
return {dispose, view: view.api};
|
|
212
330
|
};
|
|
213
331
|
}
|