@kubohiroya/turbowarp-plus-editor-git 0.7.0 → 0.9.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 +359 -15
- 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 +23 -0
- package/dist/project-change-list.d.ts +81 -0
- package/dist/project-change-model.d.ts +73 -0
- package/dist/project-source-diff.d.ts +7 -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 +11 -2
- package/dist/{repository-DplMQiuo.js → repository-VBp-UNWc.js} +134 -3
- package/dist/repository.d.ts +34 -0
- package/dist/repository.js +1 -1
- package/dist/{scratch-block-diff-renderer-CLYoOIB2.js → scratch-block-diff-renderer-DZR8ep-T.js} +708 -90
- package/dist/scratch-block-diff-renderer.d.ts +69 -6
- package/dist/style.css +48 -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 +42 -12
- package/src/project-asset-paths.ts +33 -0
- package/src/project-assets.ts +78 -0
- package/src/project-change-list.tsx +210 -0
- package/src/project-change-model.ts +212 -0
- package/src/project-source-diff.css +31 -2
- package/src/project-source-diff.tsx +17 -7
- package/src/project-source-review-model.ts +4 -1
- package/src/project-source-review.tsx +67 -27
- package/src/repository.ts +107 -2
- package/src/scratch-block-diff-renderer.ts +129 -11
- package/src/style.css +17 -0
|
@@ -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,28 +166,37 @@ 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;
|
|
171
|
+
/** Shows only the code of this stage or sprite, without the overview and the changes outside the code. */
|
|
172
|
+
target?: {isStage: boolean; name: string};
|
|
139
173
|
}
|
|
140
174
|
|
|
141
|
-
/** Compares two versions of a project.source.json by stage, sprite and
|
|
142
|
-
export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback}: ProjectSourceComparisonProps) {
|
|
175
|
+
/** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
|
|
176
|
+
export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset, target}: ProjectSourceComparisonProps) {
|
|
143
177
|
const labels = locale.startsWith('ja') ? ja : en;
|
|
178
|
+
const only = target && JSON.stringify([target.isStage, target.isStage || target.name]);
|
|
144
179
|
const comparison = useMemo(() => {
|
|
180
|
+
let result;
|
|
145
181
|
try {
|
|
146
|
-
|
|
182
|
+
result = compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
|
|
147
183
|
} catch {
|
|
148
184
|
return null;
|
|
149
185
|
}
|
|
150
|
-
|
|
186
|
+
if (!only) return result;
|
|
187
|
+
return {properties: [], targets: result.targets.filter(item => JSON.stringify([item.isStage, item.isStage || item.name]) === only)
|
|
188
|
+
.map(item => ({...item, properties: []}))};
|
|
189
|
+
}, [before, after, only]);
|
|
151
190
|
if (!comparison) return fallback === undefined ? <p role="alert">{labels.invalid}</p> :
|
|
152
191
|
<><p role="alert" className="twgit-review-unreadable">{labels.unreadable}</p>{fallback}</>;
|
|
153
192
|
return <section className="twgit-block-diff twgit-review twgit-review-comparison" aria-label={labels.comparison}>
|
|
154
|
-
<header className="twgit-review-overview">
|
|
193
|
+
{!only && <header className="twgit-review-overview">
|
|
155
194
|
<dl>
|
|
156
195
|
<div><dt>{labels.targets}</dt><dd>{comparison.targets.length}</dd></div>
|
|
157
196
|
<div><dt>{labels.scripts}</dt><dd>{countScripts(comparison)}</dd></div>
|
|
158
197
|
</dl>
|
|
159
|
-
</header>
|
|
160
|
-
<ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}/>
|
|
198
|
+
</header>}
|
|
199
|
+
<ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}/>
|
|
161
200
|
</section>;
|
|
162
201
|
}
|
|
163
202
|
|
|
@@ -194,7 +233,7 @@ export function ProjectSourceReview({title, description, verification, files, re
|
|
|
194
233
|
<div><dt>{labels.files}</dt><dd>{entries.length}</dd></div>
|
|
195
234
|
<div><dt>{labels.targets}</dt><dd>{targets}</dd></div>
|
|
196
235
|
<div><dt>{labels.scripts}</dt><dd>{scripts}</dd></div>
|
|
197
|
-
{scripts > 0 && <div><dt>{labels.
|
|
236
|
+
{scripts > 0 && <div><dt>{labels.reviewed}</dt><dd>{reviewed.size} / {scripts}</dd></div>}
|
|
198
237
|
</dl>
|
|
199
238
|
{longBody && <details className="twgit-review-body">
|
|
200
239
|
<summary>{labels.fullDescription}</summary>
|
|
@@ -211,6 +250,7 @@ export function ProjectSourceReview({title, description, verification, files, re
|
|
|
211
250
|
{'status' in file && <p>{labels.related} · {labels[file.status]}</p>}
|
|
212
251
|
{(invalid || file.unavailable) && <p role="alert">{labels.invalid}</p>}
|
|
213
252
|
{comparison && <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}
|
|
253
|
+
loadAsset={'loadAsset' in file ? file.loadAsset : undefined}
|
|
214
254
|
review={{file: file.path, keys: reviewed, set: setReviewed}}/>}
|
|
215
255
|
</section>)}
|
|
216
256
|
</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,14 @@ 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'};
|
|
31
|
+
/**
|
|
32
|
+
* Two versions of a project folder as a person sees them: the last commit and the working tree, so changes split
|
|
33
|
+
* across the index and the working tree read as one; or one commit and its first parent.
|
|
34
|
+
*/
|
|
35
|
+
export type ProjectComparison = {kind: 'worktree'} | {kind: 'commit'; commit: string};
|
|
36
|
+
const projectSourceLimit = 16 * 1024 * 1024;
|
|
25
37
|
type Metadata = Map<string, Uint8Array | null>;
|
|
26
38
|
export interface Snapshot {
|
|
27
39
|
branch: string | undefined;
|
|
@@ -211,7 +223,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
211
223
|
const a = before.get(path), b = after.get(path);
|
|
212
224
|
if (a?.oid !== b?.oid || a?.mode !== b?.mode) result.push({
|
|
213
225
|
path, group: 'commit', before: a?.oid ?? null, after: b?.oid ?? null,
|
|
214
|
-
oldMode: a?.mode ?? null, newMode: b?.mode ?? null, worktree: false
|
|
226
|
+
oldMode: a?.mode ?? null, newMode: b?.mode ?? null, worktree: false, commit: oid
|
|
215
227
|
});
|
|
216
228
|
}
|
|
217
229
|
await assertSnapshot(snapshot);
|
|
@@ -256,6 +268,79 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
256
268
|
return patch === undefined ? {...base, tooLarge: true, patch: '', text} :
|
|
257
269
|
{...base, patch: 'diff --git a/file b/file\n' + patch.replace(/^=+\n/, ''), text};
|
|
258
270
|
};
|
|
271
|
+
const sourceOf = async (snapshot: Snapshot, change: FileChange, version: ProjectVersion): Promise<Source | null> => {
|
|
272
|
+
if (change[version] === null) return null;
|
|
273
|
+
switch (change.group) {
|
|
274
|
+
case 'staged': return version === 'after' ? {kind: 'index'} : snapshot.head ? {kind: 'tree', ref: snapshot.head} : null;
|
|
275
|
+
case 'unstaged': return version === 'after' ? {kind: 'worktree'} : {kind: 'index'};
|
|
276
|
+
case 'untracked': return version === 'after' ? {kind: 'worktree'} : null;
|
|
277
|
+
case 'commit': {
|
|
278
|
+
if (!change.commit) return null;
|
|
279
|
+
if (version === 'after') return {kind: 'tree', ref: change.commit};
|
|
280
|
+
const parent = (await git.readCommit({...options, cache, oid: change.commit})).commit.parent[0];
|
|
281
|
+
return parent ? {kind: 'tree', ref: parent} : null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
/** Reads one regular file of one version, or `null` when it is missing or larger than the limit. */
|
|
286
|
+
const readVersion = async (source: Source, path: string, limit: number): Promise<Uint8Array | null> => {
|
|
287
|
+
if (source.kind === 'worktree') {
|
|
288
|
+
const stat = await fs.promises.stat('/' + path).catch(() => null);
|
|
289
|
+
if (!stat?.isFile() || stat.size > limit) return null;
|
|
290
|
+
const bytes = await readOptional(fs, '/' + path);
|
|
291
|
+
return bytes && bytes.byteLength <= limit ? bytes : null;
|
|
292
|
+
}
|
|
293
|
+
const entry = (await entries(source.kind === 'tree' ? source.ref : null, path)).get(path);
|
|
294
|
+
if (!entry || (entry.mode !== 0o100644 && entry.mode !== 0o100755)) return null;
|
|
295
|
+
const {blob} = await git.readBlob({...options, cache, oid: entry.oid});
|
|
296
|
+
return blob.byteLength <= limit ? blob : null;
|
|
297
|
+
};
|
|
298
|
+
const comparisonSource = async (snapshot: Snapshot, comparison: ProjectComparison, version: ProjectVersion): Promise<Source | null> => {
|
|
299
|
+
if (comparison.kind === 'worktree') return version === 'after' ? {kind: 'worktree'} : snapshot.head ? {kind: 'tree', ref: snapshot.head} : null;
|
|
300
|
+
if (version === 'after') return {kind: 'tree', ref: comparison.commit};
|
|
301
|
+
const parent = (await git.readCommit({...options, cache, oid: comparison.commit})).commit.parent[0];
|
|
302
|
+
return parent ? {kind: 'tree', ref: parent} : null;
|
|
303
|
+
};
|
|
304
|
+
const readAsset = (snapshot: Snapshot, change: FileChange, version: ProjectVersion, md5ext: string, signal: AbortSignal) =>
|
|
305
|
+
readFolderAsset(snapshot, pathParts(change.path).slice(0, -1), () => sourceOf(snapshot, change, version), md5ext, signal);
|
|
306
|
+
const readFolderAsset = async (snapshot: Snapshot, folder: string[], resolve: () => Promise<Source | null>, md5ext: string,
|
|
307
|
+
signal: AbortSignal) => {
|
|
308
|
+
if (signal.aborted) return null;
|
|
309
|
+
fs.clearReads();
|
|
310
|
+
await assertSnapshot(snapshot);
|
|
311
|
+
const source = await resolve();
|
|
312
|
+
if (!source) return null;
|
|
313
|
+
const settings = await readVersion(source, [...folder, 'sb3-source.json'].join('/'), folderSettingsLimit);
|
|
314
|
+
const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
|
|
315
|
+
if (!directory || signal.aborted) return null;
|
|
316
|
+
const bytes = await readVersion(source, [...folder, ...directory, md5ext].join('/'), assetLimit);
|
|
317
|
+
await fs.checkReads();
|
|
318
|
+
return bytes && !signal.aborted ? new Blob([bytes as Uint8Array<ArrayBuffer>]) : null;
|
|
319
|
+
};
|
|
320
|
+
/** Reads the project file and the assets directory of both versions of a project folder. */
|
|
321
|
+
const readProject = async (snapshot: Snapshot, folder: string, comparison: ProjectComparison) => {
|
|
322
|
+
fs.clearReads();
|
|
323
|
+
await assertSnapshot(snapshot);
|
|
324
|
+
const parts = pathParts(folder);
|
|
325
|
+
const read = async (version: ProjectVersion) => {
|
|
326
|
+
const source = await comparisonSource(snapshot, comparison, version);
|
|
327
|
+
if (!source) return {source: null, assetsDirectory: 'assets'};
|
|
328
|
+
const bytes = await readVersion(source, [...parts, 'project.source.json'].join('/'), projectSourceLimit);
|
|
329
|
+
const settings = await readVersion(source, [...parts, 'sb3-source.json'].join('/'), folderSettingsLimit);
|
|
330
|
+
const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
|
|
331
|
+
let text: string | null = null;
|
|
332
|
+
if (bytes) {
|
|
333
|
+
try {text = new TextDecoder('utf-8', {fatal: true}).decode(bytes);} catch {
|
|
334
|
+
throw new GitPanelError('unsupported');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return {source: text, assetsDirectory: directory ? directory.join('/') : 'assets'};
|
|
338
|
+
};
|
|
339
|
+
const before = await read('before'), after = await read('after');
|
|
340
|
+
await assertSnapshot(snapshot);
|
|
341
|
+
await fs.checkReads();
|
|
342
|
+
return {before: before.source, after: after.source, assetsDirectory: {before: before.assetsDirectory, after: after.assetsDirectory}};
|
|
343
|
+
};
|
|
259
344
|
const directoryAt = async (path: string) => {
|
|
260
345
|
let directory = root;
|
|
261
346
|
for (const part of pathParts(path)) directory = await directory.getDirectoryHandle(part);
|
|
@@ -302,7 +387,27 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
302
387
|
}),
|
|
303
388
|
read: (scope?: string) => serial(() => read(scope)),
|
|
304
389
|
readDiff: (snapshot: Snapshot, change: FileChange) => serial(() => readDiff(snapshot, change)),
|
|
305
|
-
historyChanges: (snapshot: Snapshot, oid: string) => serial(() => historyChanges(snapshot, oid))
|
|
390
|
+
historyChanges: (snapshot: Snapshot, oid: string) => serial(() => historyChanges(snapshot, oid)),
|
|
391
|
+
/**
|
|
392
|
+
* Reads the assets of both versions of a selected project file, for thumbnails. Staged changes compare HEAD with
|
|
393
|
+
* the index, unstaged ones the index with the working tree, untracked ones nothing with the working tree, and
|
|
394
|
+
* commit changes the first parent with the commit. Each version's sb3-source.json names its assets directory.
|
|
395
|
+
* Reads never write, and resolve `null` for anything missing, invalid, larger than 10 MiB or changed since the snapshot.
|
|
396
|
+
*/
|
|
397
|
+
assetLoader: (snapshot: Snapshot, change: FileChange): ProjectAssetLoader => ({version, md5ext, signal}) =>
|
|
398
|
+
!isAssetName(md5ext) || signal.aborted ? Promise.resolve(null) :
|
|
399
|
+
serial(() => readAsset(snapshot, change, version, md5ext, signal)).catch(() => null),
|
|
400
|
+
/**
|
|
401
|
+
* Reads the project file of a folder in both versions of a comparison, with each version's assets directory.
|
|
402
|
+
* `null` stands for a version without the file. Throws `changed` when the repository changed since the snapshot.
|
|
403
|
+
*/
|
|
404
|
+
readProject: (snapshot: Snapshot, folder: string, comparison: ProjectComparison) =>
|
|
405
|
+
serial(() => readProject(snapshot, folder, comparison)),
|
|
406
|
+
/** Reads the assets of a folder in both versions of a comparison, like `assetLoader`. */
|
|
407
|
+
projectAssetLoader: (snapshot: Snapshot, folder: string, comparison: ProjectComparison): ProjectAssetLoader =>
|
|
408
|
+
({version, md5ext, signal}) => !isAssetName(md5ext) || signal.aborted ? Promise.resolve(null) :
|
|
409
|
+
serial(() => readFolderAsset(snapshot, pathParts(folder), () => comparisonSource(snapshot, comparison, version),
|
|
410
|
+
md5ext, signal)).catch(() => null)
|
|
306
411
|
};
|
|
307
412
|
}
|
|
308
413
|
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
|
}
|
package/src/style.css
CHANGED
|
@@ -27,3 +27,20 @@
|
|
|
27
27
|
.twgit-view-switch { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 10px; }
|
|
28
28
|
.twgit-review-unreadable { margin: 6px 0; padding: 8px; background: color-mix(in srgb, #d9822b 16%, Canvas); }
|
|
29
29
|
.twgit-review-comparison .twgit-review-overview dl { margin: 4px 0 8px; }
|
|
30
|
+
.twgit-element-list > section { margin: 6px 0 10px; }
|
|
31
|
+
.twgit-element-target { display: flex; align-items: center; gap: 6px; margin: 10px 0 4px; font-size: 14px; }
|
|
32
|
+
.twgit-element-target img, .twgit-element img, .twgit-element-thumbnail { width: 28px; height: 28px; object-fit: contain; flex: none; }
|
|
33
|
+
.twgit-element-thumbnail { display: inline-block; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
|
|
34
|
+
.twgit-panel .twgit-element { display: flex; align-items: center; gap: 6px; padding-inline-start: 10px; }
|
|
35
|
+
.twgit-element-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
36
|
+
.twgit-file-list { margin-top: 10px; }
|
|
37
|
+
.twgit-element-pictures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
|
38
|
+
.twgit-element-picture { margin: 0; padding: 10px; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 6px; }
|
|
39
|
+
.twgit-element-picture img { display: block; max-width: 100%; max-height: 260px; margin: 8px auto 0; object-fit: contain;
|
|
40
|
+
background: repeating-conic-gradient(color-mix(in srgb, CanvasText 6%, Canvas) 0 25%, Canvas 0 50%) 0 0 / 16px 16px; }
|
|
41
|
+
.twgit-element-values { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
42
|
+
.twgit-element-values th, .twgit-element-values td { text-align: start; vertical-align: top; padding: 6px 8px;
|
|
43
|
+
border-bottom: 1px solid color-mix(in srgb, CanvasText 15%, Canvas); overflow-wrap: anywhere; }
|
|
44
|
+
.twgit-element-values td { font-family: ui-monospace, monospace; }
|
|
45
|
+
.twgit-element-files { margin-top: 12px; }
|
|
46
|
+
.twgit-panel .twgit-element span.twgit-element-name { width: auto; font-family: inherit; }
|