@kubohiroya/turbowarp-plus-editor-git 0.0.0-stage → 0.7.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 (59) hide show
  1. package/LICENSE +373 -0
  2. package/README.ja.md +105 -0
  3. package/README.md +35 -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 +17 -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 +16 -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 +3556 -0
  15. package/dist/libesm-OWRWzmu_.js +739 -0
  16. package/dist/messages.d.ts +112 -0
  17. package/dist/mutation-fs.d.ts +58 -0
  18. package/dist/objectSpread2-Dkd68nJH.js +101 -0
  19. package/dist/project-source-diff.d.ts +22 -0
  20. package/dist/project-source-diff.js +169 -0
  21. package/dist/project-source-patch.d.ts +39 -0
  22. package/dist/project-source-review-model.d.ts +20 -0
  23. package/dist/project-source-review.d.ts +34 -0
  24. package/dist/pull-request-panel.d.ts +8 -0
  25. package/dist/remote-operations.d.ts +5 -0
  26. package/dist/repository-DplMQiuo.js +17212 -0
  27. package/dist/repository.d.ts +61 -0
  28. package/dist/repository.js +1 -0
  29. package/dist/scratch-block-diff-renderer-CLYoOIB2.js +1464 -0
  30. package/dist/scratch-block-diff-renderer.d.ts +65 -0
  31. package/dist/smart-http-BmHEkE7K.js +303 -0
  32. package/dist/smart-http.d.ts +60 -0
  33. package/dist/smart-http.js +1 -0
  34. package/dist/style.css +109 -0
  35. package/package.json +60 -4
  36. package/src/block-diff-location.ts +63 -0
  37. package/src/block-diff-preview.tsx +36 -0
  38. package/src/browser.ts +5 -0
  39. package/src/connection-settings.tsx +103 -0
  40. package/src/file-diff-view.tsx +43 -0
  41. package/src/filesystem.ts +125 -0
  42. package/src/github-pull-requests.ts +162 -0
  43. package/src/index.tsx +349 -0
  44. package/src/messages.ts +64 -0
  45. package/src/mutation-fs.ts +204 -0
  46. package/src/project-source-diff.css +79 -0
  47. package/src/project-source-diff.tsx +132 -0
  48. package/src/project-source-patch.ts +126 -0
  49. package/src/project-source-review-model.ts +113 -0
  50. package/src/project-source-review.tsx +217 -0
  51. package/src/pull-request-panel.tsx +134 -0
  52. package/src/remote-operations.ts +90 -0
  53. package/src/repository.ts +308 -0
  54. package/src/scratch-block-diff-renderer.ts +213 -0
  55. package/src/smart-http.ts +190 -0
  56. package/src/style.css +29 -0
  57. package/src/styles.d.ts +1 -0
  58. package/tsconfig.json +18 -0
  59. package/vite.config.ts +26 -0
@@ -0,0 +1,308 @@
1
+ import * as git from 'isomorphic-git';
2
+ import {createTwoFilesPatch} from 'diff';
3
+ import {createReadOnlyFs, errorCode, GitPanelError, pathParts, readOptional, sameBytes} from './filesystem.js';
4
+ import {runRemoteOperation} from './remote-operations.js';
5
+ import type {GetRemoteSession, RemoteOperation} from './remote-operations.js';
6
+
7
+ export {createReadOnlyFs, GitPanelError} from './filesystem.js';
8
+ export type ChangeGroup = 'staged' | 'unstaged' | 'untracked' | 'commit';
9
+ export interface FileChange {
10
+ path: string;
11
+ group: ChangeGroup;
12
+ before: string | null;
13
+ after: string | null;
14
+ worktree: boolean;
15
+ oldMode: number | null;
16
+ newMode: number | null;
17
+ }
18
+ export interface Commit {
19
+ oid: string;
20
+ message: string;
21
+ author: string;
22
+ timestamp: number;
23
+ }
24
+ interface Entry {oid: string; mode: number}
25
+ type Metadata = Map<string, Uint8Array | null>;
26
+ export interface Snapshot {
27
+ branch: string | undefined;
28
+ head: string | null;
29
+ branches: string[];
30
+ commits: Commit[];
31
+ changes: FileChange[];
32
+ scope: string;
33
+ metadata: Metadata;
34
+ /** Size and time fingerprint taken before the read. Compare it with `probe(scope)` to see later changes. */
35
+ probe: string;
36
+ }
37
+ export interface FileDiff {
38
+ binary: boolean;
39
+ patch: string;
40
+ /**
41
+ * Both versions as text, for views that read the content instead of the patch. A missing version is empty.
42
+ * Absent for binary content and above the text limit, except for project.source.json up to 16 MiB.
43
+ */
44
+ text?: {before: string; after: string};
45
+ beforeSize: number;
46
+ afterSize: number;
47
+ tooLarge: boolean;
48
+ }
49
+ const emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
50
+ const textLimit = 1024 * 1024;
51
+ const inScope = (path: string, scope: string) => !scope || path === scope || path.startsWith(scope + '/');
52
+ // Above this many work-tree files, probes only watch Git metadata. Edits then need a manual refresh.
53
+ const probeLimit = 5000;
54
+ const sameMetadata = (a: Metadata, b: Metadata) => a.size === b.size && [...a].every(([path, bytes]) => sameBytes(bytes, b.get(path) ?? null));
55
+
56
+ export async function openRepository(root: FileSystemDirectoryHandle) {
57
+ // Linked worktrees and submodules may point outside the granted directory.
58
+ try {await root.getDirectoryHandle('.git');} catch (error) {
59
+ if (['NotFoundError', 'TypeMismatchError'].includes((error as DOMException).name)) throw new GitPanelError('invalid');
60
+ throw error;
61
+ }
62
+ const fs = createReadOnlyFs(root);
63
+ let tail = Promise.resolve();
64
+ const serial = <T>(operation: () => Promise<T>) => {
65
+ const result = tail.then(operation);
66
+ tail = result.then(() => {}, () => {});
67
+ return result;
68
+ };
69
+ const options = {fs, dir: '/', gitdir: '/.git'};
70
+ let cache = {};
71
+ let pushNeedsFetch = false;
72
+ const metadata = async (): Promise<Metadata> => {
73
+ const head = await readOptional(fs, '/.git/HEAD');
74
+ if (!head) throw new GitPanelError('invalid');
75
+ const text = new TextDecoder().decode(head).trim();
76
+ const paths = ['/.git/HEAD', '/.git/index', '/.git/packed-refs', '/.git/shallow', '/.git/config'];
77
+ if (text.startsWith('ref: refs/heads/')) {
78
+ const ref = text.slice(5);
79
+ pathParts(ref);
80
+ paths.push('/.git/' + ref);
81
+ } else if (!/^[0-9a-f]{40}$/.test(text)) throw new GitPanelError('invalid');
82
+ const result: Metadata = new Map();
83
+ for (const path of paths) {
84
+ if (await readOptional(fs, path + '.lock')) throw new GitPanelError('changed');
85
+ result.set(path, await readOptional(fs, path));
86
+ }
87
+ return result;
88
+ };
89
+ await metadata();
90
+
91
+ const fileAt = async (parts: string[]) => {
92
+ let directory = root;
93
+ for (const name of parts.slice(0, -1)) directory = await directory.getDirectoryHandle(name);
94
+ return (await directory.getFileHandle(parts[parts.length - 1])).getFile();
95
+ };
96
+ const optionalFile = async (path: string) => {
97
+ try {return await fileAt(pathParts(path));} catch (error) {
98
+ if (['NotFoundError', 'TypeMismatchError'].includes((error as DOMException).name)) return null;
99
+ throw error;
100
+ }
101
+ };
102
+ const stamp = (file: File | null) => file ? `${file.size}:${file.lastModified}` : '-';
103
+ /**
104
+ * Fingerprints Git metadata and the work tree in `scope` from sizes and modification times, without
105
+ * reading file contents other than HEAD. Returns 'locked' while Git holds a lock on that metadata.
106
+ */
107
+ const probe = async (scope = ''): Promise<string> => {
108
+ const parts = pathParts(scope);
109
+ const headFile = await optionalFile('.git/HEAD');
110
+ if (!headFile) return 'locked';
111
+ const head = (await headFile.text()).trim();
112
+ const paths = ['.git/HEAD', '.git/index', '.git/packed-refs', '.git/shallow', '.git/config'];
113
+ if (head.startsWith('ref: refs/heads/')) {
114
+ try {pathParts(head.slice(5));} catch {return 'locked';}
115
+ paths.push('.git/' + head.slice(5));
116
+ }
117
+ const lines: string[] = [];
118
+ for (const path of paths) {
119
+ if (await optionalFile(path + '.lock')) return 'locked';
120
+ lines.push(`${path} ${stamp(await optionalFile(path))}`);
121
+ }
122
+ let start: FileSystemDirectoryHandle | null = root;
123
+ try {for (const name of parts) start = await start.getDirectoryHandle(name);} catch {start = null;}
124
+ const files: string[] = [];
125
+ const visit = async (directory: FileSystemDirectoryHandle, prefix: string): Promise<boolean> => {
126
+ const entries = (directory as FileSystemDirectoryHandle & {values(): AsyncIterable<FileSystemHandle>}).values();
127
+ for await (const entry of entries) {
128
+ const path = prefix + entry.name;
129
+ if (path === '.git') continue;
130
+ if (entry.kind === 'directory') {
131
+ if (!await visit(entry as FileSystemDirectoryHandle, path + '/')) return false;
132
+ } else {
133
+ files.push(`${path} ${stamp(await (entry as FileSystemFileHandle).getFile())}`);
134
+ if (files.length > probeLimit) return false;
135
+ }
136
+ }
137
+ return true;
138
+ };
139
+ const prefix = parts.length ? parts.join('/') + '/' : '';
140
+ const complete = start ? await visit(start, prefix) : true;
141
+ if (complete) lines.push(...files.sort());
142
+ else lines.push('work tree not watched');
143
+ return lines.join('\n');
144
+ };
145
+
146
+ const entries = async (ref: string | null, scope: string): Promise<Map<string, Entry>> => {
147
+ const tree = ref === null ? git.STAGE() : git.TREE({ref});
148
+ const result = await git.walk({
149
+ ...options, cache, trees: [tree],
150
+ map: async (path, [entry]) => {
151
+ if (path !== '.' && !inScope(path, scope) && !scope.startsWith(path + '/')) return null;
152
+ if (!entry || await entry.type() === 'tree') return;
153
+ const mode = await entry.mode();
154
+ if (mode === 0o160000 || mode === 0o120000) throw new GitPanelError('unsupported');
155
+ return {path, oid: await entry.oid(), mode};
156
+ }
157
+ }) as {path: string; oid: string; mode: number}[];
158
+ return new Map(result.map(({path, oid, mode}) => [path, {oid, mode}]));
159
+ };
160
+ const assertSnapshot = async (snapshot: Snapshot) => {
161
+ if (!sameMetadata(snapshot.metadata, await metadata())) throw new GitPanelError('changed');
162
+ };
163
+ const read = async (scope = ''): Promise<Snapshot> => {
164
+ scope = pathParts(scope).join('/');
165
+ fs.clearReads();
166
+ cache = {};
167
+ // Taken first, so a change during the read differs from the stored value and triggers another read.
168
+ const fingerprint = await probe(scope);
169
+ const before = await metadata();
170
+ let head: string | null;
171
+ try {head = await git.resolveRef({...options, ref: 'HEAD'});} catch (error) {
172
+ if (errorCode(error) !== 'NotFoundError') throw error;
173
+ head = null;
174
+ }
175
+ const branch = await git.currentBranch(options);
176
+ const branches = await git.listBranches(options);
177
+ const commits = head ? (await git.log({...options, cache, ref: head, depth: 50})).map(({oid, commit}) => ({
178
+ oid, message: commit.message, author: commit.author.name, timestamp: commit.author.timestamp
179
+ })) : [];
180
+ const matrix = await git.statusMatrix({...options, cache, refresh: false, filepaths: [scope || '.']});
181
+ const staged = await entries(null, scope);
182
+ const committed = await entries(head || emptyTree, scope);
183
+ const changes: FileChange[] = [];
184
+ for (const [path, headState, workState, stageState] of matrix) {
185
+ const previous = committed.get(path);
186
+ const next = staged.get(path);
187
+ if (headState !== stageState || previous?.mode !== next?.mode) {
188
+ changes.push({path, group: 'staged', before: previous?.oid ?? null, after: next?.oid ?? null,
189
+ worktree: false, oldMode: previous?.mode ?? null, newMode: next?.mode ?? null});
190
+ }
191
+ if (workState !== stageState) {
192
+ const bytes = workState ? await fs.promises.readFile('/' + path) as Uint8Array : null;
193
+ const oid = bytes ? (await git.hashBlob({object: bytes})).oid : null;
194
+ changes.push({path, group: !next && bytes ? 'untracked' : 'unstaged',
195
+ before: next?.oid ?? null, after: oid, worktree: true,
196
+ oldMode: next?.mode ?? null, newMode: bytes ? (next?.mode ?? 0o100644) : null});
197
+ }
198
+ }
199
+ if (!sameMetadata(before, await metadata())) throw new GitPanelError('changed');
200
+ await fs.checkReads();
201
+ return {branch: branch || undefined, head, branches, commits, changes, scope, metadata: before, probe: fingerprint};
202
+ };
203
+ const historyChanges = async (snapshot: Snapshot, oid: string): Promise<FileChange[]> => {
204
+ fs.clearReads();
205
+ await assertSnapshot(snapshot);
206
+ const {commit} = await git.readCommit({...options, cache, oid});
207
+ const before = await entries(commit.parent[0] || emptyTree, snapshot.scope);
208
+ const after = await entries(oid, snapshot.scope);
209
+ const result: FileChange[] = [];
210
+ for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
211
+ const a = before.get(path), b = after.get(path);
212
+ if (a?.oid !== b?.oid || a?.mode !== b?.mode) result.push({
213
+ path, group: 'commit', before: a?.oid ?? null, after: b?.oid ?? null,
214
+ oldMode: a?.mode ?? null, newMode: b?.mode ?? null, worktree: false
215
+ });
216
+ }
217
+ await assertSnapshot(snapshot);
218
+ await fs.checkReads();
219
+ return result;
220
+ };
221
+ const readDiff = async (snapshot: Snapshot, change: FileChange): Promise<FileDiff> => {
222
+ fs.clearReads();
223
+ await assertSnapshot(snapshot);
224
+ pathParts(change.path);
225
+ const before = change.before ? (await git.readBlob({...options, cache, oid: change.before})).blob : new Uint8Array();
226
+ let after: Uint8Array = new Uint8Array();
227
+ if (change.after) {
228
+ if (change.worktree) {
229
+ const bytes = await readOptional(fs, '/' + change.path);
230
+ if (!bytes || (await git.hashBlob({object: bytes})).oid !== change.after) throw new GitPanelError('changed');
231
+ after = bytes;
232
+ } else after = (await git.readBlob({...options, cache, oid: change.after})).blob;
233
+ } else if (change.worktree && await readOptional(fs, '/' + change.path)) throw new GitPanelError('changed');
234
+ await assertSnapshot(snapshot);
235
+ await fs.checkReads();
236
+ const size = Math.max(before.byteLength, after.byteLength);
237
+ const tooLarge = size > textLimit;
238
+ const binary = before.includes(0) || after.includes(0);
239
+ const base = {beforeSize: before.byteLength, afterSize: after.byteLength, binary, tooLarge};
240
+ // Project sources are compared as blocks, which stays usable well past the text diff limit.
241
+ const sourceLimit = change.path.split('/').pop() === 'project.source.json' ? 16 * 1024 * 1024 : textLimit;
242
+ if (binary || size > sourceLimit) return {...base, patch: ''};
243
+ const decoder = new TextDecoder('utf-8', {fatal: true});
244
+ let oldText: string, newText: string;
245
+ try {oldText = decoder.decode(before); newText = decoder.decode(after);} catch {
246
+ return {...base, binary: true, patch: ''};
247
+ }
248
+ if (change.worktree && await git.getConfig({...options, path: 'core.autocrlf'}) === 'true') {
249
+ newText = newText.replace(/\r\n/g, '\n');
250
+ }
251
+ const text = {before: oldText, after: newText};
252
+ if (tooLarge) return {...base, patch: '', text};
253
+ // The path is rendered separately, so newline characters cannot alter patch headers.
254
+ const patch = createTwoFilesPatch(change.before ? 'a/file' : '/dev/null', change.after ? 'b/file' : '/dev/null',
255
+ oldText, newText, undefined, undefined, {context: 3, timeout: 1000});
256
+ return patch === undefined ? {...base, tooLarge: true, patch: '', text} :
257
+ {...base, patch: 'diff --git a/file b/file\n' + patch.replace(/^=+\n/, ''), text};
258
+ };
259
+ const directoryAt = async (path: string) => {
260
+ let directory = root;
261
+ for (const part of pathParts(path)) directory = await directory.getDirectoryHandle(part);
262
+ return directory;
263
+ };
264
+ const browse = async (path = '') => {
265
+ path = pathParts(path).join('/');
266
+ const directory = await directoryAt(path);
267
+ const children: string[] = [];
268
+ let isProject = false;
269
+ for await (const entry of (directory as FileSystemDirectoryHandle & {values(): AsyncIterable<FileSystemHandle>}).values()) {
270
+ if (entry.kind === 'directory' && entry.name !== '.git') children.push(entry.name);
271
+ if (entry.kind === 'file' && entry.name === 'project.source.json') isProject = true;
272
+ }
273
+ return {path, children: children.sort(), isProject};
274
+ };
275
+ const openProject = async (path: string) => {
276
+ const parts = pathParts(path);
277
+ let directory = root;
278
+ for (const part of parts) {
279
+ directory = await directory.getDirectoryHandle(part);
280
+ for await (const entry of (directory as FileSystemDirectoryHandle & {values(): AsyncIterable<FileSystemHandle>}).values()) {
281
+ if (entry.name === '.git') throw new GitPanelError('invalid');
282
+ }
283
+ }
284
+ const parentParts = await root.resolve(directory);
285
+ if (parentParts === null || parentParts.join('/') !== parts.join('/')) throw new GitPanelError('invalid');
286
+ await directory.getFileHandle('project.source.json');
287
+ return directory;
288
+ };
289
+ return {root, openProject, browse, probe,
290
+ readOrigin: () => serial(() => git.getConfig({...options, path: 'remote.origin.url'})),
291
+ remoteOperation: (operation: RemoteOperation, getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) =>
292
+ serial(async () => {
293
+ if (operation === 'push' && pushNeedsFetch) throw new GitPanelError('pushUnknown');
294
+ try {
295
+ await runRemoteOperation(root, operation, getSession, guard, signal);
296
+ if (operation === 'fetch') pushNeedsFetch = false;
297
+ } catch (error) {
298
+ if (operation === 'push' && error instanceof GitPanelError &&
299
+ (error.code === 'pushUnknown' || error.code === 'pushRefresh')) pushNeedsFetch = true;
300
+ throw error;
301
+ }
302
+ }),
303
+ read: (scope?: string) => serial(() => read(scope)),
304
+ readDiff: (snapshot: Snapshot, change: FileChange) => serial(() => readDiff(snapshot, change)),
305
+ historyChanges: (snapshot: Snapshot, oid: string) => serial(() => historyChanges(snapshot, oid))
306
+ };
307
+ }
308
+ export type Repository = Awaited<ReturnType<typeof openRepository>>;
@@ -0,0 +1,213 @@
1
+ import {isRecord} from './project-source-patch.js';
2
+ import type {BlockChangeKind, BlockPart, BlockSide, BlockTable} from './project-source-patch.js';
3
+ import {markBlockLocation} from './block-diff-location.js';
4
+ import type {DiffSvgBlock} from './block-diff-location.js';
5
+
6
+ interface BlockWorkspace {
7
+ dispose(): void;
8
+ markFocused(): void;
9
+ getBlocksBoundingBox(): {width: number; height: number};
10
+ getBlockById(id: string): DiffSvgBlock | null;
11
+ options?: {pathToMedia?: string};
12
+ }
13
+ export interface ScratchBlocksPort {
14
+ inject(container: HTMLElement, options: {readOnly: true; sounds: false; media?: string}): BlockWorkspace;
15
+ svgResize(workspace: BlockWorkspace): void;
16
+ getMainWorkspace(): BlockWorkspace | null;
17
+ mainWorkspace: BlockWorkspace | null;
18
+ Events: {disable(): void; enable(): void};
19
+ Blocks: Record<string, unknown>;
20
+ defineBlocksWithJsonArray?(definitions: Record<string, unknown>[]): void;
21
+ OUTPUT_SHAPE_ROUND?: number;
22
+ Xml: {textToDom(xml: string): Element; domToWorkspace(xml: Element, workspace: BlockWorkspace): unknown};
23
+ }
24
+ export interface ScratchBlockDiffRendererOptions {
25
+ getBlockly(): ScratchBlocksPort | Promise<ScratchBlocksPort>;
26
+ showUnregisteredBlocks?: boolean;
27
+ locale?: string;
28
+ deserializeBlocks(blocks: BlockTable): Record<string, {id: string; topLevel?: boolean}>;
29
+ createBlockContainer(): {
30
+ createBlock(block: {id: string}, deferProjectChanged: true): void;
31
+ blockToXML(id: string): string | undefined;
32
+ };
33
+ }
34
+ export interface ScriptBlockMark {
35
+ id: string;
36
+ kind: BlockChangeKind;
37
+ parts: readonly BlockPart[];
38
+ }
39
+
40
+ export type ProjectBlockRenderer = (args: BlockSide & {
41
+ container: HTMLElement; withContext: boolean; parts?: readonly BlockPart[]; marks?: readonly ScriptBlockMark[]; signal: AbortSignal;
42
+ }) => Promise<(() => void) | undefined>;
43
+
44
+ function scriptRoot({target, id}: BlockSide): string {
45
+ const visited = new Set<string>();
46
+ for (;;) {
47
+ const block = target.blocks[id];
48
+ if (!isRecord(block) || typeof block.parent !== 'string' || !Object.hasOwn(target.blocks, block.parent)) return id;
49
+ if (visited.has(id)) throw new Error('Cyclic block parents.');
50
+ visited.add(id);
51
+ id = block.parent;
52
+ }
53
+ }
54
+
55
+ function safeMutation(value: unknown, root: boolean, depth = 0): boolean {
56
+ if (!isRecord(value) || depth > 32 || value.tagName !== (root ? 'mutation' : 'arg') || !Array.isArray(value.children)) return false;
57
+ return Object.entries(value).every(([key, item]) => key === 'children' ||
58
+ (/^[a-zA-Z][\w-]*$/.test(key) && ['string', 'number', 'boolean'].includes(typeof item))) &&
59
+ value.children.every(child => safeMutation(child, false, depth + 1));
60
+ }
61
+
62
+ function selectBlocks({target, id}: BlockSide, root: string, withContext: boolean): BlockTable {
63
+ const selected: BlockTable = Object.create(null);
64
+ const visiting = new Set<string>();
65
+ let blockCount = 0;
66
+ function visit(blockId: string, followNext: boolean, depth: number) {
67
+ if (visiting.has(blockId) || depth > 128) throw new Error('Block preview exceeds its graph limit.');
68
+ if (Object.hasOwn(selected, blockId) || !Object.hasOwn(target.blocks, blockId)) return;
69
+ if (++blockCount > 800) throw new Error('Block preview exceeds its graph limit.');
70
+ if (blockId === '__proto__') throw new Error('Invalid block ID.');
71
+ const data = target.blocks[blockId];
72
+ if (Array.isArray(data)) {
73
+ selected[blockId] = data.length > 3 ? [...data.slice(0, 3), 20, 20] : [...data];
74
+ return;
75
+ }
76
+ if (!isRecord(data) || typeof data.opcode !== 'string') throw new Error('Invalid Scratch block.');
77
+ const block = structuredClone(data);
78
+ if (block.mutation && !safeMutation(block.mutation, true)) throw new Error('Unsupported block mutation.');
79
+ block.x = 20;
80
+ block.y = 20;
81
+ block.topLevel = blockId === root;
82
+ if (blockId === root) block.shadow = false;
83
+ delete block.comment;
84
+ if (!followNext) block.next = null;
85
+ selected[blockId] = block;
86
+ visiting.add(blockId);
87
+ if (isRecord(block.inputs)) {
88
+ for (const [name, input] of Object.entries(block.inputs)) {
89
+ if (!withContext && /^SUBSTACK\d*$/.test(name)) {
90
+ delete block.inputs[name];
91
+ continue;
92
+ }
93
+ if (!Array.isArray(input)) continue;
94
+ for (const child of input.slice(1, 3)) {
95
+ if (typeof child === 'string') visit(child, withContext, depth + 1);
96
+ }
97
+ }
98
+ }
99
+ if (typeof block.next === 'string') visit(block.next, true, depth + 1);
100
+ visiting.delete(blockId);
101
+ }
102
+ visit(root, withContext, 0);
103
+ if (!Object.hasOwn(selected, id)) throw new Error('The changed block is not reachable.');
104
+ return selected;
105
+ }
106
+
107
+ const placeholderPrefix = 'twgit_review_placeholder_';
108
+ let placeholderSerial = 0;
109
+
110
+ function statementInput(element: Element) {
111
+ return element.localName === 'statement' || /^SUBSTACK\d*$/.test(element.getAttribute('name') ?? '');
112
+ }
113
+
114
+ function placeholderDefinition(element: Element, opcode: string, type: string, blockly: ScratchBlocksPort, locale: string) {
115
+ const text = (locale.startsWith('ja') ? '未登録: ' : 'Unregistered: ') + opcode.slice(0, 120);
116
+ const args: Record<string, unknown>[] = [{type: 'field_label', text}];
117
+ const branches: string[] = [];
118
+ for (const child of Array.from(element.children)) {
119
+ const name = child.getAttribute('name');
120
+ if (!name) continue;
121
+ if (child.localName === 'field') args.push({type: 'field_input', name, text: ''});
122
+ else if (statementInput(child)) branches.push(name);
123
+ else if (child.localName === 'value') args.push({type: 'input_value', name});
124
+ }
125
+ const parent = element.parentElement;
126
+ const reporter = parent?.localName === 'value' && !statementInput(parent);
127
+ const definition: Record<string, unknown> = {
128
+ type, message0: args.map((_, index) => '%' + (index + 1)).join(' '), args0: args, inputsInline: true,
129
+ colour: '#687488', colourSecondary: '#566274', colourTertiary: '#4b5564',
130
+ ...(reporter ? {output: null, outputShape: blockly.OUTPUT_SHAPE_ROUND} : {previousStatement: null, nextStatement: null})
131
+ };
132
+ for (const [index, name] of branches.entries()) {
133
+ definition['message' + (index + 1)] = '%1';
134
+ definition['args' + (index + 1)] = [{type: 'input_statement', name}];
135
+ }
136
+ return definition;
137
+ }
138
+
139
+ /** Use the host's Scratch VM serializer and Scratch Blocks definitions in an isolated workspace. */
140
+ export function createScratchBlocksDiffRenderer(options: ScratchBlockDiffRendererOptions): ProjectBlockRenderer {
141
+ return async ({container, target, id, withContext, parts = [], marks, signal}) => {
142
+ const blockly = await options.getBlockly();
143
+ if (signal.aborted) return;
144
+ const root = withContext ? scriptRoot({target, id}) : id;
145
+ const selected = selectBlocks({target, id}, root, withContext);
146
+ const compactRoot = Array.isArray(selected[root]);
147
+ const blocks = options.deserializeBlocks(selected);
148
+ const serializedRoot = compactRoot ? Object.values(blocks).find(block => block.topLevel)?.id : root;
149
+ if (!serializedRoot) throw new Error('Missing Scratch block.');
150
+ const vmBlocks = options.createBlockContainer();
151
+ for (const block of Object.values(blocks)) vmBlocks.createBlock(block, true);
152
+ const xml = vmBlocks.blockToXML(serializedRoot);
153
+ if (!xml) throw new Error('Missing Scratch block XML.');
154
+ const dom = blockly.Xml.textToDom('<xml>' + xml + '</xml>');
155
+ const main = blockly.getMainWorkspace();
156
+ const surface = container.ownerDocument.createElement('div');
157
+ surface.className = 'twgit-block-diff-surface';
158
+ container.append(surface);
159
+ let workspace: BlockWorkspace | undefined;
160
+ const placeholders: string[] = [];
161
+ const dispose = () => {
162
+ const active = blockly.getMainWorkspace();
163
+ blockly.Events.disable();
164
+ try {
165
+ workspace?.dispose();
166
+ } finally {
167
+ if (active === workspace) blockly.mainWorkspace = main;
168
+ for (const type of placeholders) delete blockly.Blocks[type];
169
+ blockly.Events.enable();
170
+ surface.remove();
171
+ }
172
+ };
173
+ blockly.Events.disable();
174
+ try {
175
+ for (const element of Array.from(dom.querySelectorAll('block,shadow'))) {
176
+ const opcode = element.getAttribute('type') ?? '';
177
+ if (!opcode.startsWith(placeholderPrefix) && Object.hasOwn(blockly.Blocks, opcode)) continue;
178
+ if (!options.showUnregisteredBlocks || !blockly.defineBlocksWithJsonArray) throw new Error('Unregistered Scratch block.');
179
+ let alias: string;
180
+ do {alias = placeholderPrefix + ++placeholderSerial;} while (Object.hasOwn(blockly.Blocks, alias));
181
+ placeholders.push(alias);
182
+ blockly.defineBlocksWithJsonArray([placeholderDefinition(element, opcode, alias, blockly,
183
+ options.locale ?? container.ownerDocument.documentElement.lang)]);
184
+ element.setAttribute('type', alias);
185
+ }
186
+ workspace = blockly.inject(surface, {readOnly: true, sounds: false, media: main?.options?.pathToMedia});
187
+ workspace.markFocused = () => {};
188
+ blockly.Xml.domToWorkspace(dom, workspace);
189
+ if (marks) {
190
+ for (const mark of marks) {
191
+ const changed = workspace.getBlockById(mark.id === root ? serializedRoot : mark.id);
192
+ if (!changed) continue;
193
+ changed.getSvgRoot()?.classList.add('twgit-block-diff-' + mark.kind);
194
+ markBlockLocation(changed, mark.parts);
195
+ }
196
+ } else {
197
+ const changed = workspace.getBlockById(id === root ? serializedRoot : id);
198
+ if (changed) markBlockLocation(changed, parts);
199
+ }
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);
204
+ } catch (error) {
205
+ dispose();
206
+ throw error;
207
+ } finally {
208
+ blockly.mainWorkspace = main;
209
+ blockly.Events.enable();
210
+ }
211
+ return dispose;
212
+ };
213
+ }