@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.
Files changed (36) hide show
  1. package/README.ja.md +22 -6
  2. package/README.md +9 -1
  3. package/dist/block-diff-preview.d.ts +18 -2
  4. package/dist/file-diff-view.d.ts +3 -1
  5. package/dist/index.js +359 -15
  6. package/dist/{libesm-OWRWzmu_.js → project-asset-paths-KV715791.js} +23 -1
  7. package/dist/project-asset-paths.d.ts +12 -0
  8. package/dist/project-assets.d.ts +23 -0
  9. package/dist/project-change-list.d.ts +81 -0
  10. package/dist/project-change-model.d.ts +73 -0
  11. package/dist/project-source-diff.d.ts +7 -1
  12. package/dist/project-source-diff.js +10 -7
  13. package/dist/project-source-review-model.d.ts +4 -1
  14. package/dist/project-source-review.d.ts +11 -2
  15. package/dist/{repository-DplMQiuo.js → repository-VBp-UNWc.js} +134 -3
  16. package/dist/repository.d.ts +34 -0
  17. package/dist/repository.js +1 -1
  18. package/dist/{scratch-block-diff-renderer-CLYoOIB2.js → scratch-block-diff-renderer-DZR8ep-T.js} +708 -90
  19. package/dist/scratch-block-diff-renderer.d.ts +69 -6
  20. package/dist/style.css +48 -2
  21. package/package.json +7 -2
  22. package/src/block-diff-preview.tsx +89 -8
  23. package/src/file-diff-view.tsx +4 -3
  24. package/src/github-pull-requests.ts +73 -6
  25. package/src/index.tsx +42 -12
  26. package/src/project-asset-paths.ts +33 -0
  27. package/src/project-assets.ts +78 -0
  28. package/src/project-change-list.tsx +210 -0
  29. package/src/project-change-model.ts +212 -0
  30. package/src/project-source-diff.css +31 -2
  31. package/src/project-source-diff.tsx +17 -7
  32. package/src/project-source-review-model.ts +4 -1
  33. package/src/project-source-review.tsx +67 -27
  34. package/src/repository.ts +107 -2
  35. package/src/scratch-block-diff-renderer.ts +129 -11
  36. 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: 'スクリプトの比較', reviewed: '確認済み', reviewedScripts: 'スクリプト確認済み', before: '変更前', after: '変更後',
32
+ scripts: 'コードの変更', scriptCount: (count: number) => `コードの変更 · ${count} か所`,
33
+ reviewed: '確認済み', before: '変更前', after: '変更後',
30
34
  updated: '変更', added: '追加', removed: '削除', reorganized: '再編',
31
- absent: 'この版にはスクリプトがありません', loading: 'スクリプトを描画中…',
32
- unavailable: 'このスクリプトを描画できません。',
35
+ absent: 'この版にはこのコードがありません', loading: 'コードを描画中…',
36
+ unavailable: 'このコードを描画できません。',
33
37
  related: '関連ファイル', properties: 'コード以外の変更', invalid: 'この作品の差分を読み込めません。',
34
38
  flag: '緑の旗が押されたとき', broadcast: 'を受け取ったとき', key: 'キーが押されたとき',
35
- click: 'このスプライトが押されたとき', script: 'スクリプト', noChanges: '作品に内容の変更はありません。',
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: 'Script comparisons', reviewed: 'Reviewed', reviewedScripts: 'Reviewed scripts', before: 'Before', after: 'After',
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 script in this version', loading: 'Rendering script…',
43
- unavailable: 'This script could not be rendered.',
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: 'Script', noChanges: 'No changes to the project contents.',
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
- /** Lists the changed stages and sprites of one project file, each with its script comparisons. */
109
- function ComparisonTargets({comparison, labels, renderBlock, review}: {
110
- comparison: Comparison; labels: typeof ja; renderBlock: ProjectBlockRenderer;
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>{target.name}{target.scripts.length > 0 && <small>{labels.scripts} · {target.scripts.length}</small>}</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 script, as the pull request review does. */
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
- return compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
182
+ result = compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
147
183
  } catch {
148
184
  return null;
149
185
  }
150
- }, [before, after]);
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.reviewedScripts}</dt><dd>{reviewed.size} / {scripts}</dd></div>}
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: {readOnly: true; sounds: false; media?: string}): BlockWorkspace;
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
- }) => Promise<(() => void) | undefined>;
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
- workspace = blockly.inject(surface, {readOnly: true, sounds: false, media: main?.options?.pathToMedia});
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
- const bounds = workspace.getBlocksBoundingBox();
201
- surface.style.width = Math.max(100, Math.ceil(bounds.width) + 48) + 'px';
202
- surface.style.height = Math.max(64, Math.ceil(bounds.height) + 48) + 'px';
203
- blockly.svgResize(workspace);
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; }