@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,79 @@
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
+ .twgit-block-diff-preview { overflow: auto; max-height: 420px; }
26
+ .twgit-block-diff-surface { position: relative; min-width: 100%; height: 64px; }
27
+ /* These markers also apply to the live Editor workspace returned by the host. */
28
+ .twgit-changed-block > .blocklyPath { stroke: var(--twgit-change, #635bce); stroke-width: 2.5px; }
29
+ .twgit-changed-block-part { outline: 2.5px solid #b4510a; outline-offset: 2px; }
30
+ .twgit-block-diff-legend { font-size: .9em; }
31
+ .twgit-block-diff-side > button { margin-top: 8px; }
32
+ .twgit-block-diff-card > label { display: block; padding: 7px 14px 10px; }
33
+ @container (max-width: 440px) {
34
+ .twgit-block-diff-sides { grid-template-columns: minmax(0, 1fr); }
35
+ .twgit-block-diff-side + .twgit-block-diff-side { border-inline-start: 0; border-block-start: 1px solid color-mix(in srgb, CanvasText 20%, Canvas); }
36
+ }
37
+
38
+ .twgit-review { --twgit-review-line: color-mix(in srgb, CanvasText 18%, Canvas); }
39
+ .twgit-review-overview { border-bottom: 1px solid var(--twgit-review-line); padding-bottom: 14px; }
40
+ .twgit-review-overview > p:first-child { margin: 0 0 5px; color: color-mix(in srgb, CanvasText 70%, Canvas); font-weight: 600; }
41
+ .twgit-review-overview h2 { font-size: 1.65em; }
42
+ .twgit-review-description { white-space: pre-wrap; overflow-wrap: anywhere; margin: 5px 0; }
43
+ .twgit-review-body summary { cursor: pointer; text-decoration: underline; }
44
+ .twgit-review-overview dl { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; }
45
+ .twgit-review-overview dl > div { min-width: 90px; padding: 7px 12px; border: 1px solid var(--twgit-review-line); border-radius: 6px; }
46
+ .twgit-review-overview dt { font-size: .85em; }
47
+ .twgit-review-overview dd { margin: 0; font-size: 1.3em; font-weight: 650; }
48
+ .twgit-review-checklist h3, .twgit-review-checklist ul { margin: 0; }
49
+ .twgit-review-checklist ul { padding-inline-start: 22px; }
50
+ .twgit-review-files-title { margin: 13px 0 6px; }
51
+ .twgit-review-file { border: 1px solid var(--twgit-review-line); border-radius: 8px; overflow: hidden; margin-bottom: 10px; }
52
+ .twgit-review-file > header { background: color-mix(in srgb, CanvasText 5%, Canvas); padding: 8px 12px; }
53
+ .twgit-review-file > header h4, .twgit-review-file > header p { margin: 0; overflow-wrap: anywhere; }
54
+ .twgit-review-file > p, .twgit-review-target > p { margin: 6px 12px; }
55
+ .twgit-review-target { padding: 6px 12px 10px; }
56
+ .twgit-review-target h5 { font-size: 1em; margin: 0 0 5px; }
57
+ .twgit-review-target h5 small { margin-inline-start: 6px; font-weight: 400; }
58
+ .twgit-review-script { border: 1px solid var(--twgit-review-line); border-radius: 6px; margin: 4px 0; overflow: hidden; }
59
+ .twgit-review-script > summary { cursor: pointer; padding: 7px 10px; list-style: none; }
60
+ .twgit-review-script > summary::-webkit-details-marker { display: none; }
61
+ .twgit-review-script > summary::before { content: '▸'; margin-inline-end: 7px; }
62
+ .twgit-review-script[open] > summary::before { content: '▾'; }
63
+ .twgit-review-script > summary:focus-visible { outline: 2px solid Highlight; outline-offset: -2px; }
64
+ .twgit-review-script > summary > * { margin-inline-end: 8px; }
65
+ .twgit-review-script[open] > summary { border-bottom: 1px solid var(--twgit-review-line); }
66
+ .twgit-review-script h4 { margin: 0 0 3px; }
67
+ .twgit-review-script .twgit-block-diff-side + .twgit-block-diff-side { border-inline-start: 0; border-block-start: 1px solid var(--twgit-review-line); }
68
+ .twgit-review-script .twgit-block-diff-sides > div + div { border-inline-start: 1px solid var(--twgit-review-line); }
69
+ .twgit-review-status { font-size: .85em; padding: 2px 5px; border: 1px solid var(--twgit-review-line); border-radius: 4px; }
70
+ .twgit-review-checked { display: block; padding: 6px 12px 9px; }
71
+ @container (max-width: 440px) {
72
+ .twgit-review-script .twgit-block-diff-sides > div + div { border-inline-start: 0; border-block-start: 1px solid var(--twgit-review-line); }
73
+ }
74
+
75
+ .twgit-pr-number { display: flex; flex-wrap: wrap; align-items: end; gap: 6px; margin-bottom: 8px; }
76
+ .twgit-pr-number input { display: block; width: 10em; max-width: 100%; }
77
+ .twgit-pr-paging, .twgit-pr-revision { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
78
+ .twgit-pr-revision code { overflow-wrap: anywhere; }
79
+ .twgit-review .twgit-block-diff-side h4 { font-size: 1em; }
@@ -0,0 +1,132 @@
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} 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 {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
19
+ export type {ProjectBlockRenderer, ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch-block-diff-renderer.js';
20
+
21
+ const en = {
22
+ title: 'Block changes', before: 'Before', after: 'After', added: 'Added', removed: 'Removed', updated: 'Changed',
23
+ inputs: 'Inputs', fields: 'Fields', opcode: 'Block type', mutation: 'Block shape', position: 'Position',
24
+ connection: 'Connections', properties: 'Other block properties',
25
+ absent: 'No block on this side', omitted: (count: number) => `${count} unchanged blocks omitted from the change list`,
26
+ context: 'Highlight in the surrounding script', more: 'Show more changes', noChanges: 'No block changes.',
27
+ locate: 'Highlight in Editor', locating: 'Finding the block in the Editor…', located: 'Highlighted in the Editor.',
28
+ missingLocation: 'This version or block is unavailable in the Editor. Open its surrounding script here.',
29
+ partLegend: 'Orange outlines mark changed inputs and fields.',
30
+ otherChanges: 'The project also has changes outside its block code.',
31
+ invalid: 'This diff could not be read. Use a full-context project diff, or provide the original project.',
32
+ loading: 'Rendering blocks…', unavailable: 'This block could not be rendered. Check the block definitions available in the host.'
33
+ };
34
+ const ja: typeof en = {
35
+ title: 'ブロックの変更', before: '変更前', after: '変更後', added: '追加', removed: '削除', updated: '変更',
36
+ inputs: '入力値', fields: 'フィールド', opcode: 'ブロックの種類', mutation: 'ブロックの形', position: '位置',
37
+ connection: 'つなぎ方', properties: 'その他のブロック属性',
38
+ absent: 'この側にはブロックがありません', omitted: count => `変更のないブロック ${count} 個を一覧から省略`,
39
+ context: '元のスクリプト内で強調', more: '続きを表示', noChanges: 'ブロックの変更はありません。',
40
+ locate: '編集画面で強調', locating: '編集画面でブロックを検索中…', located: '編集画面で強調しました。',
41
+ missingLocation: '編集画面に該当する版またはブロックがありません。元のスクリプトをここで展開できます。',
42
+ partLegend: 'オレンジの枠は、変更された入力欄やフィールドです。',
43
+ otherChanges: 'ブロックのコード以外にも作品の変更があります。',
44
+ invalid: '差分を読み込めません。全体の文脈を含む差分を使うか、変更前の作品を指定してください。',
45
+ loading: 'ブロックを描画中…', unavailable: 'ブロックを描画できません。ホストのブロック定義を確認してください。'
46
+ };
47
+
48
+ export interface ProjectSourceBlockDiffProps {
49
+ patch: string;
50
+ /** Supply the original JSON if the unified patch omits context. */
51
+ beforeSource?: string;
52
+ renderBlock: ProjectBlockRenderer;
53
+ locateBlock?: ProjectBlockLocator;
54
+ locale?: string;
55
+ }
56
+
57
+ type Version = 'before' | 'after';
58
+
59
+ const propertyLabels = new Map<string, keyof typeof en>([
60
+ ['inputs', 'inputs'], ['fields', 'fields'], ['opcode', 'opcode'], ['mutation', 'mutation'],
61
+ ['x', 'position'], ['y', 'position'], ['next', 'connection'], ['parent', 'connection'], ['topLevel', 'connection']
62
+ ]);
63
+
64
+ function ChangeCard({change, renderBlock, labels, onLocate, location}: {
65
+ change: BlockChange; renderBlock: ProjectBlockRenderer; labels: typeof en;
66
+ onLocate?: (version: Version) => void; location?: {version: Version; status: LocationStatus};
67
+ }) {
68
+ const [withContext, setWithContext] = useState(false);
69
+ const notes = [...new Set(change.fields.map(field => labels[propertyLabels.get(field) ?? 'properties']))];
70
+ const name = (change.after ?? change.before)!.target.name;
71
+ return <article className={`twgit-block-diff-card twgit-block-diff-${change.kind}`}>
72
+ <header>
73
+ <h3><span>{labels[change.kind]}</span> {name}</h3>
74
+ {!!notes.length && <p>{notes.join(' · ')}</p>}
75
+ {!!change.parts.length && <p className="twgit-block-diff-legend">{labels.partLegend}</p>}
76
+ </header>
77
+ <div className="twgit-block-diff-sides">
78
+ {(['before', 'after'] as const).map(version => <BlockPreview key={version} side={change[version]} title={labels[version]}
79
+ renderBlock={renderBlock} withContext={withContext} labels={labels} parts={change.parts}
80
+ onLocate={onLocate && (() => onLocate(version))}
81
+ locationStatus={location?.version === version ? location.status : undefined}/>)}
82
+ </div>
83
+ <label><input type="checkbox" checked={withContext} onChange={event => setWithContext(event.currentTarget.checked)}/> {labels.context}</label>
84
+ </article>;
85
+ }
86
+
87
+ const pageSize = 12;
88
+ const empty = {changes: [], unchanged: 0, otherChanges: false};
89
+
90
+ export function ProjectSourceBlockDiff({patch, beforeSource, renderBlock, locateBlock, locale = 'en'}: ProjectSourceBlockDiffProps) {
91
+ const labels = locale.startsWith('ja') ? ja : en;
92
+ const result = useMemo(() => {
93
+ try {
94
+ const projects = readProjectSourceDiff(patch, beforeSource);
95
+ return projects ? compareProjectBlocks(...projects) : empty;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }, [patch, beforeSource]);
100
+ const [page, setPage] = useState({result, visible: pageSize});
101
+ const request = useRef<AbortController>();
102
+ const [location, setLocation] = useState<{result: typeof result; key: string; version: Version; locator: ProjectBlockLocator; status: LocationStatus}>();
103
+ useEffect(() => () => request.current?.abort(), [result, locateBlock]);
104
+ const locate = async (change: BlockChange, version: Version) => {
105
+ const side = change[version];
106
+ if (!side || !locateBlock) return;
107
+ request.current?.abort();
108
+ const controller = new AbortController();
109
+ request.current = controller;
110
+ const selected = {result, key: change.key, version, locator: locateBlock};
111
+ setLocation({...selected, status: 'locating'});
112
+ let found = false;
113
+ try {
114
+ found = await locateBlock({...side, version, parts: change.parts, signal: controller.signal});
115
+ } catch {
116
+ // The host may replace the active project while resolving its workspace.
117
+ }
118
+ if (!controller.signal.aborted) setLocation({...selected, status: found ? 'located' : 'missingLocation'});
119
+ };
120
+ if (!result) return <p role="alert">{labels.invalid}</p>;
121
+ const visible = page.result === result ? page.visible : pageSize;
122
+ return <section className="twgit-block-diff" aria-label={labels.title}>
123
+ <h2>{labels.title} · {result.changes.length}</h2>
124
+ {result.unchanged > 0 && <p>{labels.omitted(result.unchanged)}</p>}
125
+ {result.otherChanges && <p>{labels.otherChanges}</p>}
126
+ {!result.changes.length && <p>{labels.noChanges}</p>}
127
+ {result.changes.slice(0, visible).map(change => <ChangeCard key={change.key} change={change} renderBlock={renderBlock} labels={labels}
128
+ onLocate={locateBlock && (version => void locate(change, version))}
129
+ location={location?.result === result && location.locator === locateBlock && location.key === change.key ? location : undefined}/>)}
130
+ {visible < result.changes.length && <button type="button" onClick={() => setPage({result, visible: visible + pageSize})}>{labels.more}</button>}
131
+ </section>;
132
+ }
@@ -0,0 +1,126 @@
1
+ import {applyPatch, createTwoFilesPatch, parsePatch} from 'diff';
2
+
3
+ export type BlockTable = Record<string, unknown>;
4
+ export interface ProjectTarget {
5
+ name: string;
6
+ isStage: boolean;
7
+ blocks: BlockTable;
8
+ }
9
+ export type ProjectSource = Record<string, unknown> & {targets: ProjectTarget[]};
10
+
11
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
12
+ typeof value === 'object' && value !== null && !Array.isArray(value);
13
+
14
+ function readProject(source: string): ProjectSource {
15
+ const project: unknown = JSON.parse(source);
16
+ if (!isRecord(project) || !Array.isArray(project.targets) || !project.targets.every(target =>
17
+ isRecord(target) && typeof target.name === 'string' && typeof target.isStage === 'boolean' && (target.blocks === undefined || isRecord(target.blocks)))) {
18
+ throw new Error('Expected a project.source.json with Scratch targets and blocks.');
19
+ }
20
+ const parsed = project as ProjectSource;
21
+ return {...parsed, targets: parsed.targets.map(target => ({...target, blocks: target.blocks ?? {}}))};
22
+ }
23
+
24
+ /** Reads one version of a project.source.json. `null` stands for a version in which the file does not exist. */
25
+ export function parseProjectSource(source: string | null): ProjectSource {
26
+ return source === null ? {targets: []} : readProject(source);
27
+ }
28
+
29
+ /** Keep the original lines and include enough context to render both sides from the patch alone. */
30
+ export function createProjectSourceDiff(before: string, after: string): string {
31
+ readProject(before);
32
+ readProject(after);
33
+ const patch = createTwoFilesPatch('a/project.source.json', 'b/project.source.json', before, after,
34
+ undefined, undefined, {context: Infinity, timeout: 1000});
35
+ if (patch === undefined) throw new Error('Project source diff exceeded its time limit.');
36
+ return 'diff --git a/project.source.json b/project.source.json\n' + patch.replace(/^=+\n/, '');
37
+ }
38
+
39
+ export function readProjectSourceDiff(patch: string, beforeSource?: string): [ProjectSource, ProjectSource] | null {
40
+ const [file, extra] = parsePatch(patch);
41
+ if (!file || extra || !file.oldFileName || !file.newFileName) throw new Error('Expected a single unified text diff.');
42
+ if (beforeSource !== undefined) {
43
+ const before = readProject(beforeSource);
44
+ const after = applyPatch(beforeSource, file);
45
+ if (after === false) throw new Error('The original project does not match this diff.');
46
+ return [before, readProject(after)];
47
+ }
48
+ if (!file.hunks.length) return null;
49
+ const before: string[] = [], after: string[] = [];
50
+ for (const hunk of file.hunks) {
51
+ if (hunk.oldStart !== before.length + 1 || hunk.newStart !== after.length + 1) {
52
+ throw new Error('The diff omits project context.');
53
+ }
54
+ for (const line of hunk.lines) {
55
+ if (line.startsWith(' ') || line.startsWith('-')) before.push(line.slice(1));
56
+ if (line.startsWith(' ') || line.startsWith('+')) after.push(line.slice(1));
57
+ }
58
+ }
59
+ return [readProject(before.join('\n')), readProject(after.join('\n'))];
60
+ }
61
+
62
+ export function equal(a: unknown, b: unknown): boolean {
63
+ if (a === b) return true;
64
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((value, i) => equal(value, b[i]));
65
+ if (!isRecord(a) || !isRecord(b)) return false;
66
+ const keys = Object.keys(a);
67
+ return keys.length === Object.keys(b).length && keys.every(key => Object.hasOwn(b, key) && equal(a[key], b[key]));
68
+ }
69
+
70
+ export const targetKey = (target: ProjectTarget) => target.isStage ? 'stage' : 'sprite:' + target.name;
71
+
72
+ export interface BlockSide { target: ProjectTarget; id: string }
73
+ export interface BlockPart { kind: 'input' | 'field'; name: string }
74
+
75
+ function changedParts(before: unknown, after: unknown): BlockPart[] {
76
+ if (!isRecord(before) || !isRecord(after)) return [];
77
+ const result: BlockPart[] = [];
78
+ // A shadow hidden behind a reporter is not the field shown in the workspace.
79
+ const visibleInput = (input: unknown) => Array.isArray(input) ? input[1] ?? input[2] : input;
80
+ for (const group of ['inputs', 'fields'] as const) {
81
+ const oldGroup = isRecord(before[group]) ? before[group] : {};
82
+ const newGroup = isRecord(after[group]) ? after[group] : {};
83
+ for (const name of new Set([...Object.keys(oldGroup), ...Object.keys(newGroup)])) {
84
+ const a = oldGroup[name], b = newGroup[name];
85
+ if (group === 'inputs' ? !equal(visibleInput(a), visibleInput(b)) : !equal(a, b)) {
86
+ result.push({kind: group === 'inputs' ? 'input' : 'field', name});
87
+ }
88
+ }
89
+ }
90
+ return result;
91
+ }
92
+ export type BlockChangeKind = 'added' | 'removed' | 'updated';
93
+ export interface BlockChange {
94
+ key: string;
95
+ before?: BlockSide;
96
+ after?: BlockSide;
97
+ kind: BlockChangeKind;
98
+ fields: string[];
99
+ parts: BlockPart[];
100
+ }
101
+
102
+ export function compareProjectBlocks(before: ProjectSource, after: ProjectSource) {
103
+ const oldTargets = new Map(before.targets.map(target => [targetKey(target), target]));
104
+ const newTargets = new Map(after.targets.map(target => [targetKey(target), target]));
105
+ const changes: BlockChange[] = [];
106
+ let unchanged = 0;
107
+ for (const key of new Set([...oldTargets.keys(), ...newTargets.keys()])) {
108
+ const oldTarget = oldTargets.get(key), newTarget = newTargets.get(key);
109
+ const oldBlocks: BlockTable = oldTarget?.blocks ?? {}, newBlocks: BlockTable = newTarget?.blocks ?? {};
110
+ for (const id of new Set([...Object.keys(oldBlocks), ...Object.keys(newBlocks)])) {
111
+ const hadBlock = Object.hasOwn(oldBlocks, id), hasBlock = Object.hasOwn(newBlocks, id);
112
+ const a = oldBlocks[id], b = newBlocks[id];
113
+ if (hadBlock && hasBlock && equal(a, b)) {
114
+ unchanged++;
115
+ continue;
116
+ }
117
+ const fields = isRecord(a) && isRecord(b) ?
118
+ [...new Set([...Object.keys(a), ...Object.keys(b)])].filter(field => !equal(a[field], b[field])) : [];
119
+ changes.push({key: JSON.stringify([key, id]), before: hadBlock && oldTarget ? {target: oldTarget, id} : undefined,
120
+ after: hasBlock && newTarget ? {target: newTarget, id} : undefined,
121
+ kind: !hadBlock ? 'added' : !hasBlock ? 'removed' : 'updated', fields, parts: changedParts(a, b)});
122
+ }
123
+ }
124
+ const withoutBlocks = (project: ProjectSource) => ({...project, targets: project.targets.map(({blocks: _, ...target}) => target)});
125
+ return {changes, unchanged, otherChanges: !equal(withoutBlocks(before), withoutBlocks(after))};
126
+ }
@@ -0,0 +1,113 @@
1
+ import {compareProjectBlocks, equal, isRecord, targetKey} from './project-source-patch.js';
2
+ import type {BlockChange, BlockSide, ProjectSource, ProjectTarget} from './project-source-patch.js';
3
+ import type {ScriptBlockMark} from './scratch-block-diff-renderer.js';
4
+
5
+ export interface ScriptReviewSide extends BlockSide { marks: ScriptBlockMark[] }
6
+ export interface ScriptReview {
7
+ id: string;
8
+ before: ScriptReviewSide[];
9
+ after: ScriptReviewSide[];
10
+ }
11
+ export interface TargetReview {
12
+ name: string;
13
+ isStage: boolean;
14
+ scripts: ScriptReview[];
15
+ properties: string[];
16
+ }
17
+
18
+ function scriptRoots(target?: ProjectTarget) {
19
+ const roots = new Map<string, string>();
20
+ if (!target) return roots;
21
+ for (const id of Object.keys(target.blocks)) {
22
+ if (roots.has(id)) continue;
23
+ const chain = new Set<string>();
24
+ let current = id;
25
+ while (!roots.has(current) && !chain.has(current)) {
26
+ chain.add(current);
27
+ const block = target.blocks[current];
28
+ if (!isRecord(block) || typeof block.parent !== 'string' || !Object.hasOwn(target.blocks, block.parent)) break;
29
+ current = block.parent;
30
+ }
31
+ const root = roots.get(current) ?? current;
32
+ for (const block of chain) roots.set(block, root);
33
+ }
34
+ return roots;
35
+ }
36
+
37
+ function groupScripts(before: ProjectTarget | undefined, after: ProjectTarget | undefined, changes: readonly BlockChange[]) {
38
+ const roots = {before: scriptRoots(before), after: scriptRoots(after)};
39
+ const parents = new Map<string, string>();
40
+ function groupOf(key: string) {
41
+ let parent = key;
42
+ while (parents.has(parent)) parent = parents.get(parent)!;
43
+ for (let current = key; current !== parent;) {
44
+ const next = parents.get(current)!;
45
+ parents.set(current, parent);
46
+ current = next;
47
+ }
48
+ return parent;
49
+ }
50
+
51
+ // Blocks retained across versions connect their original and new scripts, including splits and merges.
52
+ for (const [id, oldRoot] of roots.before) {
53
+ const newRoot = roots.after.get(id);
54
+ if (newRoot === undefined) continue;
55
+ const a = groupOf('before:' + oldRoot), b = groupOf('after:' + newRoot);
56
+ if (a !== b) parents.set(b, a);
57
+ }
58
+ const groups = new Map<string, ScriptReview>();
59
+ const sides = new Map<string, ScriptReviewSide>();
60
+ for (const version of ['before', 'after'] as const) {
61
+ const target = version === 'before' ? before : after;
62
+ if (!target) continue;
63
+ for (const root of new Set(roots[version].values())) {
64
+ const key = version + ':' + root;
65
+ const id = groupOf(key);
66
+ const group = groups.get(id) ?? {id, before: [], after: []};
67
+ const side: ScriptReviewSide = {target, id: root, marks: []};
68
+ group[version].push(side);
69
+ groups.set(id, group);
70
+ sides.set(key, side);
71
+ }
72
+ }
73
+ const affected = new Set<string>();
74
+ for (const change of changes) {
75
+ for (const version of ['before', 'after'] as const) {
76
+ const block = change[version];
77
+ if (!block) continue;
78
+ const key = version + ':' + roots[version].get(block.id);
79
+ sides.get(key)?.marks.push({id: block.id, kind: change.kind, parts: change.parts});
80
+ affected.add(groupOf(key));
81
+ }
82
+ }
83
+ return [...affected].map(id => groups.get(id)!);
84
+ }
85
+
86
+ function changedProperties(before: Record<string, unknown>, after: Record<string, unknown>, except: string) {
87
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(key => key !== except && !equal(before[key], after[key]));
88
+ }
89
+
90
+ export function compareProjectForReview(before: ProjectSource, after: ProjectSource) {
91
+ const {changes} = compareProjectBlocks(before, after);
92
+ const byTarget = new Map<string, BlockChange[]>();
93
+ for (const change of changes) {
94
+ const key = targetKey((change.after ?? change.before)!.target);
95
+ const existing = byTarget.get(key);
96
+ if (existing) existing.push(change);
97
+ else byTarget.set(key, [change]);
98
+ }
99
+ const oldTargets = new Map(before.targets.map(target => [targetKey(target), target]));
100
+ const newTargets = new Map(after.targets.map(target => [targetKey(target), target]));
101
+ const targets: TargetReview[] = [];
102
+ for (const key of new Set([...oldTargets.keys(), ...newTargets.keys()])) {
103
+ const a = oldTargets.get(key), b = newTargets.get(key);
104
+ const edits = byTarget.get(key);
105
+ const scripts = edits ? groupScripts(a, b, edits) : [];
106
+ const properties = changedProperties({...a}, {...b}, 'blocks');
107
+ if (scripts.length || properties.length) {
108
+ const {name, isStage} = (b ?? a)!;
109
+ targets.push({name, isStage, scripts, properties});
110
+ }
111
+ }
112
+ return {targets, properties: changedProperties(before, after, 'targets')};
113
+ }