@kubohiroya/turbowarp-plus-editor-git 0.0.0-stage → 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.
Files changed (63) hide show
  1. package/LICENSE +373 -0
  2. package/README.ja.md +121 -0
  3. package/README.md +43 -2
  4. package/THIRD_PARTY_NOTICES.md +5 -0
  5. package/dist/THIRD_PARTY_NOTICES.md +1186 -0
  6. package/dist/block-diff-location.d.ts +22 -0
  7. package/dist/block-diff-preview.d.ts +33 -0
  8. package/dist/browser.d.ts +4 -0
  9. package/dist/connection-settings.d.ts +35 -0
  10. package/dist/file-diff-view.d.ts +18 -0
  11. package/dist/filesystem.d.ts +89 -0
  12. package/dist/github-pull-requests.d.ts +38 -0
  13. package/dist/index.d.ts +29 -0
  14. package/dist/index.js +3563 -0
  15. package/dist/messages.d.ts +112 -0
  16. package/dist/mutation-fs.d.ts +58 -0
  17. package/dist/objectSpread2-Dkd68nJH.js +101 -0
  18. package/dist/project-asset-paths-KV715791.js +761 -0
  19. package/dist/project-asset-paths.d.ts +12 -0
  20. package/dist/project-assets.d.ts +19 -0
  21. package/dist/project-source-diff.d.ts +26 -0
  22. package/dist/project-source-diff.js +172 -0
  23. package/dist/project-source-patch.d.ts +39 -0
  24. package/dist/project-source-review-model.d.ts +23 -0
  25. package/dist/project-source-review.d.ts +38 -0
  26. package/dist/pull-request-panel.d.ts +8 -0
  27. package/dist/remote-operations.d.ts +5 -0
  28. package/dist/repository-BQkADJmK.js +17280 -0
  29. package/dist/repository.d.ts +71 -0
  30. package/dist/repository.js +1 -0
  31. package/dist/scratch-block-diff-renderer-xS8OYsRl.js +1830 -0
  32. package/dist/scratch-block-diff-renderer.d.ts +128 -0
  33. package/dist/smart-http-BmHEkE7K.js +303 -0
  34. package/dist/smart-http.d.ts +60 -0
  35. package/dist/smart-http.js +1 -0
  36. package/dist/style.css +138 -0
  37. package/package.json +65 -4
  38. package/src/block-diff-location.ts +63 -0
  39. package/src/block-diff-preview.tsx +117 -0
  40. package/src/browser.ts +5 -0
  41. package/src/connection-settings.tsx +103 -0
  42. package/src/file-diff-view.tsx +44 -0
  43. package/src/filesystem.ts +125 -0
  44. package/src/github-pull-requests.ts +229 -0
  45. package/src/index.tsx +352 -0
  46. package/src/messages.ts +64 -0
  47. package/src/mutation-fs.ts +204 -0
  48. package/src/project-asset-paths.ts +33 -0
  49. package/src/project-assets.ts +66 -0
  50. package/src/project-source-diff.css +108 -0
  51. package/src/project-source-diff.tsx +139 -0
  52. package/src/project-source-patch.ts +126 -0
  53. package/src/project-source-review-model.ts +116 -0
  54. package/src/project-source-review.tsx +250 -0
  55. package/src/pull-request-panel.tsx +134 -0
  56. package/src/remote-operations.ts +90 -0
  57. package/src/repository.ts +364 -0
  58. package/src/scratch-block-diff-renderer.ts +331 -0
  59. package/src/smart-http.ts +190 -0
  60. package/src/style.css +29 -0
  61. package/src/styles.d.ts +1 -0
  62. package/tsconfig.json +18 -0
  63. package/vite.config.ts +26 -0
@@ -0,0 +1,204 @@
1
+ import {createReadOnlyFs, errorCode, fileError, fileStat, GitPanelError, pathParts, sameBytes} from './filesystem.js';
2
+
3
+ type Entry = {kind: 'directory'} | {kind: 'file'; bytes: Uint8Array<ArrayBuffer>; time: number} | null;
4
+ type Change = {path: string; before: Entry; after: Entry};
5
+ const missing = (error: unknown) => ['ENOENT', 'ENOTDIR'].includes(errorCode(error) ?? '');
6
+
7
+ // Stage Git's writes before touching the granted directory. Pack data precedes refs.
8
+ export function createMutationFs(root: FileSystemDirectoryHandle, worktree: boolean) {
9
+ const base = createReadOnlyFs(root);
10
+ const changes = new Map<string, Change>();
11
+ const normalize = (path: string) => '/' + pathParts(path).join('/');
12
+ const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
13
+ const name = (path: string) => path.slice(path.lastIndexOf('/') + 1);
14
+ const fail = (code: string, path: string): never => {throw fileError(code, path);};
15
+ function allow(path: string, directory = false) {
16
+ const parts = pathParts(path);
17
+ const [first, second, ...rest] = parts;
18
+ if (!first || parts.slice(1).some(part => part.toLowerCase() === '.git'))
19
+ return fail('EACCES', path);
20
+ if (first === '.git') {
21
+ const writableFolder = second === 'refs' || second === 'logs' ||
22
+ (second === 'objects' && rest[0]?.toLowerCase() !== 'info');
23
+ const writableFile = !directory && !rest.length &&
24
+ ['HEAD', 'ORIG_HEAD', 'FETCH_HEAD', 'index', 'packed-refs', 'shallow'].includes(second);
25
+ if (writableFolder || writableFile) return;
26
+ } else if (first.toLowerCase() !== '.git' && worktree) {
27
+ return;
28
+ }
29
+ fail('EACCES', path);
30
+ }
31
+
32
+ async function readEntry(fs: typeof base.promises, path: string): Promise<Entry> {
33
+ try {
34
+ const stat = await fs.stat(path);
35
+ if (stat.isDirectory()) return {kind: 'directory'};
36
+ const bytes = await fs.readFile(path);
37
+ if (!(bytes instanceof Uint8Array)) throw new GitPanelError('unsupported');
38
+ return {kind: 'file', bytes: Uint8Array.from(bytes), time: stat.mtimeMs};
39
+ } catch (error) {
40
+ if (missing(error)) return null;
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ async function kind(path: string) {
46
+ const change = changes.get(path);
47
+ if (change) return change.after?.kind;
48
+ try {return (await base.promises.stat(path)).isDirectory() ? 'directory' : 'file';}
49
+ catch (error) {if (!missing(error)) throw error;}
50
+ }
51
+
52
+ async function stage(path: string, after: Entry) {
53
+ const previous = changes.get(path);
54
+ const before = previous ? previous.before : await readEntry(base.promises, path);
55
+ if (before && after && before.kind !== after.kind) throw new GitPanelError('unsupported');
56
+ changes.set(path, {path, before, after});
57
+ }
58
+
59
+ async function readdir(raw: string) {
60
+ const path = normalize(raw);
61
+ const current = await kind(path);
62
+ if (current !== 'directory') return fail(current ? 'ENOTDIR' : 'ENOENT', path);
63
+ const names = new Set<string>();
64
+ try {for (const entry of await base.promises.readdir(path)) names.add(entry);}
65
+ catch (error) {if (!missing(error)) throw error;}
66
+ for (const change of changes.values()) {
67
+ if (parent(change.path) !== path) continue;
68
+ if (change.after) names.add(name(change.path));
69
+ else names.delete(name(change.path));
70
+ }
71
+ return [...names];
72
+ }
73
+
74
+ async function remove(raw: string, directory: boolean) {
75
+ const path = normalize(raw);
76
+ allow(path, directory);
77
+ const current = await kind(path);
78
+ if (!current) return fail('ENOENT', path);
79
+ if (directory !== (current === 'directory')) return fail(directory ? 'ENOTDIR' : 'EISDIR', path);
80
+ if (directory && (await readdir(path)).length) return fail('ENOTEMPTY', path);
81
+ await stage(path, null);
82
+ }
83
+
84
+ async function stat(raw: string) {
85
+ const path = normalize(raw);
86
+ const change = changes.get(path);
87
+ if (!change) return base.promises.stat(path);
88
+ if (!change.after) return fail('ENOENT', path);
89
+ return fileStat(change.after.kind === 'directory' ? null :
90
+ {size: change.after.bytes.length, lastModified: change.after.time});
91
+ }
92
+
93
+ const fs = {promises: {
94
+ async readFile(raw: string, options?: string | {encoding?: string}) {
95
+ const path = normalize(raw);
96
+ const change = changes.get(path);
97
+ if (!change) return base.promises.readFile(path, options);
98
+ if (!change.after) return fail('ENOENT', path);
99
+ if (change.after.kind === 'directory') return fail('EISDIR', path);
100
+ const encoding = typeof options === 'string' ? options : options?.encoding;
101
+ return encoding ? new TextDecoder(encoding).decode(change.after.bytes) : change.after.bytes.slice();
102
+ },
103
+ async writeFile(raw: string, bytes: string | Uint8Array, options?: {mode?: number}) {
104
+ const path = normalize(raw);
105
+ allow(path);
106
+ if (!path.startsWith('/.git/') && options?.mode && (options.mode & 0o111))
107
+ throw new GitPanelError('unsupported');
108
+ if (await kind(parent(path)) !== 'directory') return fail('ENOENT', path);
109
+ if (await kind(path) === 'directory') return fail('EISDIR', path);
110
+ await stage(path, {kind: 'file', bytes: typeof bytes === 'string' ?
111
+ new TextEncoder().encode(bytes) : Uint8Array.from(bytes), time: Date.now()});
112
+ },
113
+ async mkdir(raw: string) {
114
+ const path = normalize(raw);
115
+ if (await kind(path)) return fail('EEXIST', path);
116
+ allow(path, true);
117
+ if (await kind(parent(path)) !== 'directory') return fail('ENOENT', path);
118
+ await stage(path, {kind: 'directory'});
119
+ },
120
+ unlink: (path: string) => remove(path, false),
121
+ rmdir: (path: string) => remove(path, true),
122
+ readdir, stat, lstat: stat,
123
+ readlink: base.promises.readlink,
124
+ symlink: base.promises.symlink,
125
+ chmod: base.promises.chmod
126
+ }};
127
+
128
+ function equal(a: Entry, b: Entry) {
129
+ return a?.kind === b?.kind && (a?.kind !== 'file' || (b?.kind === 'file' && sameBytes(a.bytes, b.bytes)));
130
+ }
131
+
132
+ async function apply(path: string, from: Entry, to: Entry) {
133
+ const current = createReadOnlyFs(root).promises;
134
+ if (path.startsWith('/.git/') && (await readEntry(current, path + '.lock') ||
135
+ await readEntry(current, '/.git/index.lock') || await readEntry(current, '/.git/packed-refs.lock')))
136
+ throw new GitPanelError('changed');
137
+ if (!equal(await readEntry(current, path), from))
138
+ throw new GitPanelError('changed');
139
+ let directory = root;
140
+ for (const part of pathParts(parent(path))) directory = await directory.getDirectoryHandle(part);
141
+ const filename = name(path);
142
+ if (!to) await directory.removeEntry(filename);
143
+ else if (to.kind === 'directory') await directory.getDirectoryHandle(filename, {create: true});
144
+ else {
145
+ const handle = await directory.getFileHandle(filename, {create: !from});
146
+ let stream: FileSystemWritableFileStream | undefined;
147
+ try {
148
+ stream = await handle.createWritable();
149
+ await stream.write(to.bytes);
150
+ await stream.close();
151
+ } catch (error) {
152
+ await stream?.abort().catch(() => {});
153
+ if (!from) {
154
+ const current = await handle.getFile();
155
+ if (current.size === 0) await directory.removeEntry(filename);
156
+ }
157
+ throw error;
158
+ }
159
+ }
160
+ }
161
+
162
+ function order(change: Change) {
163
+ const {path, before, after} = change;
164
+ const depth = pathParts(path).length;
165
+ if (!after) return before?.kind === 'directory' ? 1000 - depth : 0;
166
+ if (after.kind === 'directory') return 1000 + depth;
167
+ if (path.startsWith('/.git/objects/')) return 2000;
168
+ if (!path.startsWith('/.git/')) return 3000;
169
+ return path.startsWith('/.git/refs/heads/') || path === '/.git/HEAD' ? 5000 : 4000;
170
+ }
171
+
172
+ return {fs, async commit(guard: () => void) {
173
+ const pending = [...changes.values()].filter(change => !equal(change.before, change.after))
174
+ .sort((left, right) => order(left) - order(right));
175
+ await base.checkReads();
176
+ for (const change of pending) {
177
+ if (!equal(await readEntry(createReadOnlyFs(root).promises, change.path), change.before))
178
+ throw new GitPanelError('changed');
179
+ }
180
+ const applied: Change[] = [];
181
+ try {
182
+ for (const change of pending) {
183
+ guard();
184
+ try {
185
+ await apply(change.path, change.before, change.after);
186
+ applied.push(change);
187
+ } catch (error) {
188
+ const current = await readEntry(createReadOnlyFs(root).promises, change.path);
189
+ if (equal(current, change.after) && !(error instanceof GitPanelError)) applied.push(change);
190
+ throw error;
191
+ }
192
+ }
193
+ guard();
194
+ } catch (error) {
195
+ let rollbackFailed = false;
196
+ for (const change of applied.reverse()) {
197
+ try {await apply(change.path, change.after, change.before);}
198
+ catch {rollbackFailed = true;}
199
+ }
200
+ if (rollbackFailed) throw new GitPanelError('rollbackFailed');
201
+ throw error;
202
+ }
203
+ }};
204
+ }
@@ -0,0 +1,33 @@
1
+ // Shared by the local and GitHub asset loaders. Kept free of React so the repository entry stays UI-free.
2
+
3
+ /** The largest asset a thumbnail reads. */
4
+ export const assetLimit = 10 * 1024 * 1024;
5
+ /** The largest sb3-source.json read to find the assets directory. */
6
+ export const folderSettingsLimit = 1024 * 1024;
7
+
8
+ /** Asset names are plain `<md5>.<ext>` file names; anything else could address a path outside the assets directory. */
9
+ export const isAssetName = (name: unknown): name is string =>
10
+ typeof name === 'string' && /^[0-9A-Za-z_-]{1,128}\.[0-9A-Za-z]{1,8}$/.test(name);
11
+
12
+ /**
13
+ * The assets directory named by one version of a project folder's `sb3-source.json`, as path segments relative to the
14
+ * folder. `assets` when that file or its `assetsDirectory` field is missing or unreadable. `null` when the field names
15
+ * anything other than a plain relative path inside the folder.
16
+ */
17
+ export function assetsDirectoryParts(settings: string | null): string[] | null {
18
+ let value: unknown = 'assets';
19
+ if (settings !== null) {
20
+ try {
21
+ const parsed: unknown = JSON.parse(settings);
22
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && 'assetsDirectory' in parsed) {
23
+ value = (parsed as {assetsDirectory: unknown}).assetsDirectory;
24
+ }
25
+ } catch {
26
+ // An unreadable file names no directory, as a missing one does.
27
+ }
28
+ }
29
+ if (typeof value !== 'string' || value.length > 1024) return null;
30
+ const parts = value.split('/');
31
+ return parts.every(part => part && part !== '.' && part !== '..' && part.toLowerCase() !== '.git' &&
32
+ !/[\\\0-\x1f\x7f]/.test(part)) ? parts : null;
33
+ }
@@ -0,0 +1,66 @@
1
+ import {useEffect, useState} from 'react';
2
+ import {isRecord} from './project-source-patch.js';
3
+ import type {ProjectTarget} from './project-source-patch.js';
4
+
5
+ export type ProjectVersion = 'before' | 'after';
6
+
7
+ /**
8
+ * Reads one asset of one version of a project file by its `md5ext` name (for example `0123…cdef.svg`), from the
9
+ * assets directory of that project. Resolves `null` when that version has no readable file of that name.
10
+ */
11
+ export type ProjectAssetLoader = (request: {version: ProjectVersion; md5ext: string; signal: AbortSignal}) => Promise<Blob | null>;
12
+
13
+ const imageTypes: Record<string, string> = {
14
+ svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp'
15
+ };
16
+
17
+ /** The costume a sprite shows, or the backdrop the stage shows, when the project data names an image file for it. */
18
+ export function currentCostume(target: ProjectTarget): {name: string; md5ext: string; type: string} | null {
19
+ const {costumes, currentCostume: index} = target as unknown as Record<string, unknown>;
20
+ if (!Array.isArray(costumes)) return null;
21
+ const costume: unknown = costumes[typeof index === 'number' && Number.isInteger(index) && index >= 0 ? index : 0];
22
+ if (!isRecord(costume)) return null;
23
+ const md5ext = typeof costume.md5ext === 'string' ? costume.md5ext :
24
+ typeof costume.assetId === 'string' && typeof costume.dataFormat === 'string' ? costume.assetId + '.' + costume.dataFormat : '';
25
+ // Asset names are plain file names; anything else could address a path outside the assets directory.
26
+ const match = /^[0-9A-Za-z_-]{1,128}\.([0-9A-Za-z]{1,8})$/.exec(md5ext);
27
+ const type = match ? imageTypes[match[1].toLowerCase()] : undefined;
28
+ if (!type) return null;
29
+ return {name: typeof costume.name === 'string' ? costume.name : '', md5ext, type};
30
+ }
31
+
32
+ // Thumbnails of one target appear in its heading and in each of its frames, so one read serves all of them.
33
+ const reads = new WeakMap<ProjectAssetLoader, Map<string, Promise<Blob | null>>>();
34
+ function readAsset(loadAsset: ProjectAssetLoader, version: ProjectVersion, md5ext: string) {
35
+ let cache = reads.get(loadAsset);
36
+ if (!cache) reads.set(loadAsset, cache = new Map());
37
+ const key = version + ':' + md5ext;
38
+ let read = cache.get(key);
39
+ if (!read) {
40
+ read = loadAsset({version, md5ext, signal: new AbortController().signal}).catch(() => null);
41
+ cache.set(key, read);
42
+ }
43
+ return read;
44
+ }
45
+
46
+ /** Loads the current costume of one version of a target as an object URL for an image, or `null` while it is unavailable. */
47
+ export function useCostumeImage(target: ProjectTarget | undefined, version: ProjectVersion, loadAsset?: ProjectAssetLoader): string | null {
48
+ const costume = target && loadAsset ? currentCostume(target) : null;
49
+ const key = costume ? version + ':' + costume.md5ext : '';
50
+ const [image, setImage] = useState<{key: string; url: string}>();
51
+ useEffect(() => {
52
+ if (!costume || !loadAsset) return;
53
+ let active = true;
54
+ let url: string | undefined;
55
+ void readAsset(loadAsset, version, costume.md5ext).then(blob => {
56
+ if (!blob || !active) return;
57
+ url = URL.createObjectURL(new Blob([blob], {type: costume.type}));
58
+ setImage({key, url});
59
+ });
60
+ return () => {
61
+ active = false;
62
+ if (url) URL.revokeObjectURL(url);
63
+ };
64
+ }, [key, loadAsset]);
65
+ return image?.key === key ? image.url : null;
66
+ }
@@ -0,0 +1,108 @@
1
+ .twgit-block-diff {
2
+ color: CanvasText;
3
+ background: Canvas;
4
+ font: 14px/1.5 system-ui, sans-serif;
5
+ container-type: inline-size;
6
+ }
7
+ .twgit-block-diff h2 { margin: 0; font-size: 1.3em; }
8
+ .twgit-block-diff > p { margin: 4px 0 10px; }
9
+ .twgit-block-diff-card {
10
+ border: 1px solid color-mix(in srgb, CanvasText 25%, Canvas);
11
+ border-radius: 8px;
12
+ margin: 12px 0;
13
+ overflow: hidden;
14
+ }
15
+ .twgit-block-diff-card > header { padding: 10px 14px; background: color-mix(in srgb, CanvasText 5%, Canvas); }
16
+ .twgit-block-diff-card h3, .twgit-block-diff-card h4 { margin: 0; font-size: 1em; overflow-wrap: anywhere; }
17
+ .twgit-block-diff-card h3 span { margin-inline-end: 6px; color: CanvasText; border-bottom: 3px solid var(--twgit-change); }
18
+ .twgit-block-diff-card p { margin: 4px 0 0; }
19
+ .twgit-block-diff-added { --twgit-change: #25875d; }
20
+ .twgit-block-diff-removed { --twgit-change: #c54a53; }
21
+ .twgit-block-diff-updated { --twgit-change: #635bce; }
22
+ .twgit-block-diff-sides { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
23
+ .twgit-block-diff-side { padding: 10px 14px; min-width: 0; }
24
+ .twgit-block-diff-side + .twgit-block-diff-side { border-inline-start: 1px solid color-mix(in srgb, CanvasText 20%, Canvas); }
25
+ /* A preview is a small workspace: it scrolls and zooms inside its frame, which people can make taller. */
26
+ .twgit-block-diff-preview {
27
+ position: relative;
28
+ overflow: hidden;
29
+ resize: vertical;
30
+ min-height: 120px;
31
+ max-height: 85vh;
32
+ border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas);
33
+ border-radius: 6px;
34
+ }
35
+ .twgit-block-diff-surface { position: absolute; inset: 0; }
36
+ .twgit-block-diff-watermark { position: absolute; z-index: 2; top: 10px; right: 12px; pointer-events: none; }
37
+ .twgit-block-diff-watermark[data-navigation] { top: auto; right: var(--plus-watermark-right, 12px); bottom: var(--plus-watermark-bottom, 140px); }
38
+ .twgit-block-diff-watermark > img {
39
+ display: block;
40
+ max-width: var(--plus-watermark-size, 48px);
41
+ max-height: var(--plus-watermark-size, 48px);
42
+ opacity: .6;
43
+ }
44
+ .twgit-block-diff-link { display: block; padding: 6px 12px 0; }
45
+ /* These markers also apply to the live Editor workspace returned by the host. */
46
+ .twgit-changed-block > .blocklyPath { stroke: var(--twgit-change, #635bce); stroke-width: 2.5px; }
47
+ .twgit-changed-block-part { outline: 2.5px solid #b4510a; outline-offset: 2px; }
48
+ .twgit-block-diff-legend { font-size: .9em; }
49
+ .twgit-block-diff-side > button { margin-top: 8px; }
50
+ .twgit-block-diff-card > label { display: block; padding: 7px 14px 10px; }
51
+ @container (max-width: 440px) {
52
+ .twgit-block-diff-sides { grid-template-columns: minmax(0, 1fr); }
53
+ .twgit-block-diff-side + .twgit-block-diff-side { border-inline-start: 0; border-block-start: 1px solid color-mix(in srgb, CanvasText 20%, Canvas); }
54
+ }
55
+
56
+ .twgit-review { --twgit-review-line: color-mix(in srgb, CanvasText 18%, Canvas); }
57
+ .twgit-review-overview { border-bottom: 1px solid var(--twgit-review-line); padding-bottom: 14px; }
58
+ .twgit-review-overview > p:first-child { margin: 0 0 5px; color: color-mix(in srgb, CanvasText 70%, Canvas); font-weight: 600; }
59
+ .twgit-review-overview h2 { font-size: 1.65em; }
60
+ .twgit-review-description { white-space: pre-wrap; overflow-wrap: anywhere; margin: 5px 0; }
61
+ .twgit-review-body summary { cursor: pointer; text-decoration: underline; }
62
+ .twgit-review-overview dl { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; }
63
+ .twgit-review-overview dl > div { min-width: 90px; padding: 7px 12px; border: 1px solid var(--twgit-review-line); border-radius: 6px; }
64
+ .twgit-review-overview dt { font-size: .85em; }
65
+ .twgit-review-overview dd { margin: 0; font-size: 1.3em; font-weight: 650; }
66
+ .twgit-review-checklist h3, .twgit-review-checklist ul { margin: 0; }
67
+ .twgit-review-checklist ul { padding-inline-start: 22px; }
68
+ .twgit-review-files-title { margin: 13px 0 6px; }
69
+ .twgit-review-file { border: 1px solid var(--twgit-review-line); border-radius: 8px; overflow: hidden; margin-bottom: 10px; }
70
+ .twgit-review-file > header { background: color-mix(in srgb, CanvasText 5%, Canvas); padding: 8px 12px; }
71
+ .twgit-review-file > header h4, .twgit-review-file > header p { margin: 0; overflow-wrap: anywhere; }
72
+ .twgit-review-file > p, .twgit-review-target > p { margin: 6px 12px; }
73
+ .twgit-review-target { padding: 6px 12px 10px; }
74
+ .twgit-review-target h5 { font-size: 1em; margin: 0 0 5px; }
75
+ .twgit-review-target h5 small { margin-inline-start: 6px; font-weight: 400; }
76
+ .twgit-review-script { border: 1px solid var(--twgit-review-line); border-radius: 6px; margin: 4px 0; overflow: hidden; }
77
+ .twgit-review-script > summary { cursor: pointer; padding: 7px 10px; list-style: none; }
78
+ .twgit-review-script > summary::-webkit-details-marker { display: none; }
79
+ .twgit-review-script > summary::before { content: '▸'; margin-inline-end: 7px; }
80
+ .twgit-review-script[open] > summary::before { content: '▾'; }
81
+ .twgit-review-script > summary:focus-visible { outline: 2px solid Highlight; outline-offset: -2px; }
82
+ .twgit-review-script > summary > * { margin-inline-end: 8px; }
83
+ .twgit-review-script[open] > summary { border-bottom: 1px solid var(--twgit-review-line); }
84
+ .twgit-review-script h4 { margin: 0 0 3px; }
85
+ .twgit-review-script .twgit-block-diff-side + .twgit-block-diff-side { border-inline-start: 0; border-block-start: 1px solid var(--twgit-review-line); }
86
+ .twgit-review-script .twgit-block-diff-sides > div + div { border-inline-start: 1px solid var(--twgit-review-line); }
87
+ .twgit-review-status { font-size: .85em; padding: 2px 5px; border: 1px solid var(--twgit-review-line); border-radius: 4px; }
88
+ .twgit-review-checked { display: block; padding: 6px 12px 9px; }
89
+ @container (max-width: 440px) {
90
+ .twgit-review-script .twgit-block-diff-sides > div + div { border-inline-start: 0; border-block-start: 1px solid var(--twgit-review-line); }
91
+ }
92
+
93
+ .twgit-pr-number { display: flex; flex-wrap: wrap; align-items: end; gap: 6px; margin-bottom: 8px; }
94
+ .twgit-pr-number input { display: block; width: 10em; max-width: 100%; }
95
+ .twgit-pr-paging, .twgit-pr-revision { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
96
+ .twgit-pr-revision code { overflow-wrap: anywhere; }
97
+ .twgit-review .twgit-block-diff-side h4 { font-size: 1em; }
98
+
99
+ /* Costume (or backdrop) thumbnails in stage and sprite headings. */
100
+ .twgit-review-thumbnails { display: inline-flex; align-items: center; gap: 4px; margin-inline-end: 6px; vertical-align: middle; }
101
+ .twgit-review-thumbnails img {
102
+ width: 30px;
103
+ height: 30px;
104
+ object-fit: contain;
105
+ background: Canvas;
106
+ border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas));
107
+ border-radius: 4px;
108
+ }
@@ -0,0 +1,139 @@
1
+ import React, {useEffect, useMemo, useRef, useState} from 'react';
2
+ import {compareProjectBlocks, readProjectSourceDiff} from './project-source-patch.js';
3
+ import type {BlockChange} from './project-source-patch.js';
4
+ import type {ProjectBlockLocator} from './block-diff-location.js';
5
+ import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
6
+ import {BlockPreview, usePreviewSync} from './block-diff-preview.js';
7
+ import type {LocationStatus} from './block-diff-preview.js';
8
+ import './project-source-diff.css';
9
+
10
+ export {createGitHubPullRequests, gitHubRepositoryFromOrigin} from './github-pull-requests.js';
11
+ export {ProjectSourceComparison, ProjectSourceReview} from './project-source-review.js';
12
+ export type {ProjectSourceComparisonProps, ProjectSourceReviewFile, ProjectSourceReviewProps} from './project-source-review.js';
13
+
14
+ export {createScratchBlocksDiffLocator} from './block-diff-location.js';
15
+ export type {ProjectBlockLocation, ProjectBlockLocator, ScratchBlockLocationWorkspace} from './block-diff-location.js';
16
+ export type {BlockPart} from './project-source-patch.js';
17
+ export {createProjectSourceDiff} from './project-source-patch.js';
18
+ export {currentCostume} from './project-assets.js';
19
+ export type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
20
+ export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
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';
25
+
26
+ const en = {
27
+ title: 'Block changes', before: 'Before', after: 'After', added: 'Added', removed: 'Removed', updated: 'Changed',
28
+ inputs: 'Inputs', fields: 'Fields', opcode: 'Block type', mutation: 'Block shape', position: 'Position',
29
+ connection: 'Connections', properties: 'Other block properties',
30
+ absent: 'No block on this side', omitted: (count: number) => `${count} unchanged blocks omitted from the change list`,
31
+ context: 'Highlight in the surrounding code', more: 'Show more changes', noChanges: 'No block changes.',
32
+ locate: 'Highlight in Editor', locating: 'Finding the block in the Editor…', located: 'Highlighted in the Editor.',
33
+ missingLocation: 'This version or block is unavailable in the Editor. Open its surrounding code here.',
34
+ partLegend: 'Orange outlines mark changed inputs and fields.',
35
+ otherChanges: 'The project also has changes outside its block code.',
36
+ invalid: 'This diff could not be read. Use a full-context project diff, or provide the original project.',
37
+ loading: 'Rendering blocks…', unavailable: 'This block could not be rendered. Check the block definitions available in the host.'
38
+ };
39
+ const ja: typeof en = {
40
+ title: 'ブロックの変更', before: '変更前', after: '変更後', added: '追加', removed: '削除', updated: '変更',
41
+ inputs: '入力値', fields: 'フィールド', opcode: 'ブロックの種類', mutation: 'ブロックの形', position: '位置',
42
+ connection: 'つなぎ方', properties: 'その他のブロック属性',
43
+ absent: 'この側にはブロックがありません', omitted: count => `変更のないブロック ${count} 個を一覧から省略`,
44
+ context: '元のコード内で強調', more: '続きを表示', noChanges: 'ブロックの変更はありません。',
45
+ locate: '編集画面で強調', locating: '編集画面でブロックを検索中…', located: '編集画面で強調しました。',
46
+ missingLocation: '編集画面に該当する版またはブロックがありません。元のコードをここで展開できます。',
47
+ partLegend: 'オレンジの枠は、変更された入力欄やフィールドです。',
48
+ otherChanges: 'ブロックのコード以外にも作品の変更があります。',
49
+ invalid: '差分を読み込めません。全体の文脈を含む差分を使うか、変更前の作品を指定してください。',
50
+ loading: 'ブロックを描画中…', unavailable: 'ブロックを描画できません。ホストのブロック定義を確認してください。'
51
+ };
52
+
53
+ export interface ProjectSourceBlockDiffProps {
54
+ patch: string;
55
+ /** Supply the original JSON if the unified patch omits context. */
56
+ beforeSource?: string;
57
+ renderBlock: ProjectBlockRenderer;
58
+ locateBlock?: ProjectBlockLocator;
59
+ locale?: string;
60
+ }
61
+
62
+ type Version = 'before' | 'after';
63
+
64
+ const propertyLabels = new Map<string, keyof typeof en>([
65
+ ['inputs', 'inputs'], ['fields', 'fields'], ['opcode', 'opcode'], ['mutation', 'mutation'],
66
+ ['x', 'position'], ['y', 'position'], ['next', 'connection'], ['parent', 'connection'], ['topLevel', 'connection']
67
+ ]);
68
+
69
+ function ChangeCard({change, renderBlock, labels, onLocate, location}: {
70
+ change: BlockChange; renderBlock: ProjectBlockRenderer; labels: typeof en;
71
+ onLocate?: (version: Version) => void; location?: {version: Version; status: LocationStatus};
72
+ }) {
73
+ const [withContext, setWithContext] = useState(false);
74
+ const {sync, toggle} = usePreviewSync(labels === en ? 'en' : 'ja');
75
+ const notes = [...new Set(change.fields.map(field => labels[propertyLabels.get(field) ?? 'properties']))];
76
+ const name = (change.after ?? change.before)!.target.name;
77
+ return <article className={`twgit-block-diff-card twgit-block-diff-${change.kind}`}>
78
+ <header>
79
+ <h3><span>{labels[change.kind]}</span> {name}</h3>
80
+ {!!notes.length && <p>{notes.join(' · ')}</p>}
81
+ {!!change.parts.length && <p className="twgit-block-diff-legend">{labels.partLegend}</p>}
82
+ </header>
83
+ <div className="twgit-block-diff-sides">
84
+ {(['before', 'after'] as const).map(version => <BlockPreview key={version} side={change[version]} title={labels[version]}
85
+ renderBlock={renderBlock} withContext={withContext} labels={labels} parts={change.parts}
86
+ onLocate={onLocate && (() => onLocate(version))} version={version} sync={sync}
87
+ locationStatus={location?.version === version ? location.status : undefined}/>)}
88
+ </div>
89
+ {toggle}
90
+ <label><input type="checkbox" checked={withContext} onChange={event => setWithContext(event.currentTarget.checked)}/> {labels.context}</label>
91
+ </article>;
92
+ }
93
+
94
+ const pageSize = 12;
95
+ const empty = {changes: [], unchanged: 0, otherChanges: false};
96
+
97
+ export function ProjectSourceBlockDiff({patch, beforeSource, renderBlock, locateBlock, locale = 'en'}: ProjectSourceBlockDiffProps) {
98
+ const labels = locale.startsWith('ja') ? ja : en;
99
+ const result = useMemo(() => {
100
+ try {
101
+ const projects = readProjectSourceDiff(patch, beforeSource);
102
+ return projects ? compareProjectBlocks(...projects) : empty;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }, [patch, beforeSource]);
107
+ const [page, setPage] = useState({result, visible: pageSize});
108
+ const request = useRef<AbortController>();
109
+ const [location, setLocation] = useState<{result: typeof result; key: string; version: Version; locator: ProjectBlockLocator; status: LocationStatus}>();
110
+ useEffect(() => () => request.current?.abort(), [result, locateBlock]);
111
+ const locate = async (change: BlockChange, version: Version) => {
112
+ const side = change[version];
113
+ if (!side || !locateBlock) return;
114
+ request.current?.abort();
115
+ const controller = new AbortController();
116
+ request.current = controller;
117
+ const selected = {result, key: change.key, version, locator: locateBlock};
118
+ setLocation({...selected, status: 'locating'});
119
+ let found = false;
120
+ try {
121
+ found = await locateBlock({...side, version, parts: change.parts, signal: controller.signal});
122
+ } catch {
123
+ // The host may replace the active project while resolving its workspace.
124
+ }
125
+ if (!controller.signal.aborted) setLocation({...selected, status: found ? 'located' : 'missingLocation'});
126
+ };
127
+ if (!result) return <p role="alert">{labels.invalid}</p>;
128
+ const visible = page.result === result ? page.visible : pageSize;
129
+ return <section className="twgit-block-diff" aria-label={labels.title}>
130
+ <h2>{labels.title} · {result.changes.length}</h2>
131
+ {result.unchanged > 0 && <p>{labels.omitted(result.unchanged)}</p>}
132
+ {result.otherChanges && <p>{labels.otherChanges}</p>}
133
+ {!result.changes.length && <p>{labels.noChanges}</p>}
134
+ {result.changes.slice(0, visible).map(change => <ChangeCard key={change.key} change={change} renderBlock={renderBlock} labels={labels}
135
+ onLocate={locateBlock && (version => void locate(change, version))}
136
+ location={location?.result === result && location.locator === locateBlock && location.key === change.key ? location : undefined}/>)}
137
+ {visible < result.changes.length && <button type="button" onClick={() => setPage({result, visible: visible + pageSize})}>{labels.more}</button>}
138
+ </section>;
139
+ }