@kubohiroya/turbowarp-plus-editor-git 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +350 -13
- package/dist/project-assets.d.ts +4 -0
- package/dist/project-change-list.d.ts +81 -0
- package/dist/project-change-model.d.ts +73 -0
- package/dist/project-source-diff.d.ts +2 -0
- package/dist/project-source-diff.js +2 -2
- package/dist/project-source-review.d.ts +6 -1
- package/dist/{repository-BQkADJmK.js → repository-VBp-UNWc.js} +67 -4
- package/dist/repository.d.ts +24 -0
- package/dist/repository.js +1 -1
- package/dist/{scratch-block-diff-renderer-xS8OYsRl.js → scratch-block-diff-renderer-DZR8ep-T.js} +264 -12
- package/dist/style.css +17 -0
- package/package.json +1 -1
- package/src/index.tsx +38 -11
- package/src/project-assets.ts +19 -7
- package/src/project-change-list.tsx +210 -0
- package/src/project-change-model.ts +212 -0
- package/src/project-source-diff.tsx +3 -0
- package/src/project-source-review.tsx +12 -5
- package/src/repository.ts +53 -4
- package/src/style.css +17 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import React, {useEffect, useState} from 'react';
|
|
2
|
+
import {organizeProjectChanges, projectPropertyLabel} from './project-change-model.js';
|
|
3
|
+
import type {ChangedFile, ProjectChangeList, ProjectElementChange, ProjectTargetChanges} from './project-change-model.js';
|
|
4
|
+
import {parseProjectSource} from './project-source-patch.js';
|
|
5
|
+
import {useAssetImage, useCostumeImage} from './project-assets.js';
|
|
6
|
+
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
7
|
+
import {ProjectSourceComparison} from './project-source-review.js';
|
|
8
|
+
import type {ChangeGroup, FileChange, Repository, Snapshot} from './repository.js';
|
|
9
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
10
|
+
|
|
11
|
+
const en = {
|
|
12
|
+
title: 'Changes by project element', stage: 'Stage', code: (count: number) => `Code · ${count} ${count === 1 ? 'change' : 'changes'}`,
|
|
13
|
+
costumes: 'Costume', backdrops: 'Backdrop', sounds: 'Sound', data: 'Variables and messages', settings: 'Other settings',
|
|
14
|
+
project: 'Whole project', other: 'Files outside the project', added: 'Added', removed: 'Removed', updated: 'Changed',
|
|
15
|
+
files: 'Files and Git groups', before: 'Before', after: 'After', absent: 'Not in this version',
|
|
16
|
+
loading: 'Reading the project changes…', unreadable: 'The project data could not be read, so the changes are listed by file.',
|
|
17
|
+
fileList: 'List by file', property: 'Item', format: 'Format', rate: 'Sample rate', samples: 'Samples', file: 'File name',
|
|
18
|
+
noImage: 'No image', groups: {staged: 'Staged', unstaged: 'Unstaged', untracked: 'Untracked', commit: 'Commit'} as Record<ChangeGroup, string>
|
|
19
|
+
};
|
|
20
|
+
const ja: typeof en = {
|
|
21
|
+
title: '作品の要素ごとの変更', stage: 'ステージ', code: count => `コード · ${count} か所`,
|
|
22
|
+
costumes: 'コスチューム', backdrops: '背景', sounds: '音', data: '変数・リスト・メッセージ', settings: 'そのほかの設定',
|
|
23
|
+
project: '作品全体', other: '作品に属さないファイル', added: '追加', removed: '削除', updated: '変更',
|
|
24
|
+
files: 'ファイルと Git の区分', before: '変更前', after: '変更後', absent: 'この版にはありません',
|
|
25
|
+
loading: '作品の変更を読み込み中…', unreadable: '作品データを読み込めないため、ファイル単位で一覧にします。',
|
|
26
|
+
fileList: 'ファイル単位の一覧', property: '項目', format: '形式', rate: 'サンプリング周波数', samples: 'サンプル数', file: 'ファイル名',
|
|
27
|
+
noImage: '画像なし', groups: {staged: 'ステージ済み', unstaged: '未ステージ', untracked: '未追跡', commit: 'コミット'}
|
|
28
|
+
};
|
|
29
|
+
type Labels = typeof en;
|
|
30
|
+
export const projectChangeLabels = (locale: string): Labels => locale.startsWith('ja') ? ja : en;
|
|
31
|
+
|
|
32
|
+
export interface ProjectChanges {
|
|
33
|
+
list: ProjectChangeList;
|
|
34
|
+
sources: {before: string | null; after: string | null};
|
|
35
|
+
loadAsset: ProjectAssetLoader;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads the selected project folder in the last commit and the working tree (or a commit and its parent) and arranges
|
|
40
|
+
* the changed files by stage, sprite and element. `undefined` while there is no project; `'loading'` and `'unreadable'`
|
|
41
|
+
* tell the panel to keep the list by file.
|
|
42
|
+
*/
|
|
43
|
+
export function useProjectChanges(repository: Repository | null, snapshot: Snapshot | null, project: string | null,
|
|
44
|
+
commit: string | null, changes: readonly FileChange[]): ProjectChanges | 'loading' | 'unreadable' | undefined {
|
|
45
|
+
const [result, setResult] = useState<{changes: readonly FileChange[]; value: ProjectChanges | 'unreadable'}>();
|
|
46
|
+
const active = !!repository && !!snapshot && project !== null && changes.length > 0;
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!repository || !snapshot || project === null || !changes.length) return;
|
|
49
|
+
let current = true;
|
|
50
|
+
const comparison = commit ? {kind: 'commit' as const, commit} : {kind: 'worktree' as const};
|
|
51
|
+
void repository.readProject(snapshot, project, comparison).then(read => {
|
|
52
|
+
if (!current) return;
|
|
53
|
+
let list: ProjectChangeList;
|
|
54
|
+
try {
|
|
55
|
+
list = organizeProjectChanges({folder: project,
|
|
56
|
+
before: read.before === null ? null : parseProjectSource(read.before),
|
|
57
|
+
after: read.after === null ? null : parseProjectSource(read.after),
|
|
58
|
+
assetsDirectory: read.assetsDirectory, files: changes.map(({path, group}) => ({path, group}))});
|
|
59
|
+
} catch {
|
|
60
|
+
setResult({changes, value: 'unreadable'});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setResult({changes, value: {list, sources: {before: read.before, after: read.after},
|
|
64
|
+
loadAsset: repository.projectAssetLoader(snapshot, project, comparison)}});
|
|
65
|
+
}, () => {if (current) setResult({changes, value: 'unreadable'});});
|
|
66
|
+
return () => {current = false;};
|
|
67
|
+
}, [repository, snapshot, project, commit, changes]);
|
|
68
|
+
if (!active) return undefined;
|
|
69
|
+
// Keep the last list while a refresh reads again, so the panel does not fall back to files and back.
|
|
70
|
+
if (result?.changes === changes || (result && typeof result.value === 'object')) return result!.value;
|
|
71
|
+
return 'loading';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function TargetHeading({target, labels, loadAsset}: {target: ProjectTargetChanges; labels: Labels; loadAsset: ProjectAssetLoader}) {
|
|
75
|
+
const version: ProjectVersion = target.after ? 'after' : 'before';
|
|
76
|
+
const image = useCostumeImage(target[version], version, loadAsset);
|
|
77
|
+
return <h4 className="twgit-element-target">
|
|
78
|
+
{image ? <img src={image} alt=""/> : <span className="twgit-element-thumbnail"/>}
|
|
79
|
+
<span>{target.isStage ? labels.stage : target.name}</span>
|
|
80
|
+
{target.status !== 'updated' && <small className="twgit-review-status">{labels[target.status]}</small>}
|
|
81
|
+
</h4>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function elementTitle(element: ProjectElementChange, target: ProjectTargetChanges | null, labels: Labels, locale: string) {
|
|
85
|
+
switch (element.kind) {
|
|
86
|
+
case 'code': return labels.code(element.scripts ?? 0);
|
|
87
|
+
case 'costumes': return `${target?.isStage ? labels.backdrops : labels.costumes} · ${element.name ?? ''}`;
|
|
88
|
+
case 'sounds': return `${labels.sounds} · ${element.name ?? ''}`;
|
|
89
|
+
default: {
|
|
90
|
+
const names = (element.properties ?? []).map(key => projectPropertyLabel(key, locale)).join('・');
|
|
91
|
+
return target ? `${element.kind === 'data' ? labels.data : labels.settings} · ${names}` : names;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ElementRow({element, target, labels, locale, loadAsset, selected, busy, onSelect}: {
|
|
97
|
+
element: ProjectElementChange; target: ProjectTargetChanges | null; labels: Labels; locale: string;
|
|
98
|
+
loadAsset: ProjectAssetLoader; selected: boolean; busy: boolean; onSelect(): void;
|
|
99
|
+
}) {
|
|
100
|
+
const version: ProjectVersion = element.md5ext?.after ? 'after' : 'before';
|
|
101
|
+
const image = useAssetImage(element.kind === 'costumes' ? element.md5ext?.[version] : undefined, version, loadAsset);
|
|
102
|
+
return <li><button className="twgit-file twgit-element" data-git-element={element.kind} disabled={busy} aria-pressed={selected}
|
|
103
|
+
onClick={onSelect}>
|
|
104
|
+
{element.kind === 'costumes' && (image ? <img src={image} alt=""/> : <span className="twgit-element-thumbnail"/>)}
|
|
105
|
+
<span className="twgit-element-name">{elementTitle(element, target, labels, locale)}</span>
|
|
106
|
+
{element.status !== 'updated' && <small className="twgit-review-status">{labels[element.status]}</small>}
|
|
107
|
+
</button></li>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The change list arranged by the project's stage, sprites and elements, with files outside the project after them. */
|
|
111
|
+
export function ProjectChangeListView({changes, selected, locale, busy, onSelect, onSelectFile, fileList, fileListOpen, setFileListOpen}: {
|
|
112
|
+
changes: ProjectChanges; selected: string | null; locale: string; busy: boolean;
|
|
113
|
+
onSelect(key: string): void; onSelectFile(file: ChangedFile): void; fileList: React.ReactNode;
|
|
114
|
+
fileListOpen: boolean; setFileListOpen(open: boolean): void;
|
|
115
|
+
}) {
|
|
116
|
+
const labels = projectChangeLabels(locale);
|
|
117
|
+
const {list, loadAsset} = changes;
|
|
118
|
+
return <section className="twgit-element-list" aria-label={labels.title}>
|
|
119
|
+
{list.targets.map(target => <section key={target.key}>
|
|
120
|
+
<TargetHeading target={target} labels={labels} loadAsset={loadAsset}/>
|
|
121
|
+
<ul>{target.elements.map(element => <ElementRow key={element.key} element={element} target={target} labels={labels}
|
|
122
|
+
locale={locale} loadAsset={loadAsset} selected={selected === element.key} busy={busy} onSelect={() => onSelect(element.key)}/>)}</ul>
|
|
123
|
+
</section>)}
|
|
124
|
+
{list.project.length > 0 && <section>
|
|
125
|
+
<h4 className="twgit-element-target"><span>{labels.project}</span></h4>
|
|
126
|
+
<ul>{list.project.map(element => <ElementRow key={element.key} element={element} target={null} labels={labels}
|
|
127
|
+
locale={locale} loadAsset={loadAsset} selected={selected === element.key} busy={busy} onSelect={() => onSelect(element.key)}/>)}</ul>
|
|
128
|
+
</section>}
|
|
129
|
+
{list.other.length > 0 && <section>
|
|
130
|
+
<h4 className="twgit-element-target"><span>{labels.other}</span></h4>
|
|
131
|
+
<ul>{list.other.map(file => <li key={file.group + ':' + file.path}><button className="twgit-file" data-git-other={file.group}
|
|
132
|
+
disabled={busy} onClick={() => onSelectFile(file)}>{file.path} <small>{labels.groups[file.group]}</small></button></li>)}</ul>
|
|
133
|
+
</section>}
|
|
134
|
+
<details className="twgit-file-list" open={fileListOpen}
|
|
135
|
+
onToggle={event => setFileListOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
|
136
|
+
<summary>{labels.fileList}</summary>{fileList}</details>
|
|
137
|
+
</section>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function findElement(list: ProjectChangeList, key: string | null) {
|
|
141
|
+
if (key === null) return null;
|
|
142
|
+
for (const target of list.targets) {
|
|
143
|
+
const element = target.elements.find(item => item.key === key);
|
|
144
|
+
if (element) return {element, target};
|
|
145
|
+
}
|
|
146
|
+
const element = list.project.find(item => item.key === key);
|
|
147
|
+
return element ? {element, target: null} : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function Picture({md5ext, version, loadAsset, labels, title}: {
|
|
151
|
+
md5ext?: string; version: ProjectVersion; loadAsset: ProjectAssetLoader; labels: Labels; title: string;
|
|
152
|
+
}) {
|
|
153
|
+
const image = useAssetImage(md5ext, version, loadAsset);
|
|
154
|
+
return <figure className="twgit-element-picture">
|
|
155
|
+
<figcaption>{title}</figcaption>
|
|
156
|
+
{!md5ext ? <p>{labels.absent}</p> : image ? <img src={image} alt={title}/> : <p>{labels.noImage}</p>}
|
|
157
|
+
</figure>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const show = (value: unknown) => {
|
|
161
|
+
if (value === undefined) return '—';
|
|
162
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
163
|
+
return text.length > 400 ? text.slice(0, 400) + '…' : text;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** The details of one selected row: its code, pictures or values in both versions, and the files behind it. */
|
|
167
|
+
export function ProjectElementView({changes, selection, locale, renderBlock, onSelectFile}: {
|
|
168
|
+
changes: ProjectChanges; selection: {element: ProjectElementChange; target: ProjectTargetChanges | null}; locale: string;
|
|
169
|
+
renderBlock?: ProjectBlockRenderer; onSelectFile(file: ChangedFile): void;
|
|
170
|
+
}) {
|
|
171
|
+
const labels = projectChangeLabels(locale);
|
|
172
|
+
const {element, target} = selection;
|
|
173
|
+
const record = (version: ProjectVersion): Record<string, unknown> | undefined => {
|
|
174
|
+
if (element.kind === 'costumes' || element.kind === 'sounds') return element[version];
|
|
175
|
+
if (!target) {
|
|
176
|
+
const source = changes.sources[version];
|
|
177
|
+
try {
|
|
178
|
+
return source === null ? undefined : parseProjectSource(source) as Record<string, unknown>;
|
|
179
|
+
} catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return target[version] as Record<string, unknown> | undefined;
|
|
184
|
+
};
|
|
185
|
+
const rows = element.kind === 'sounds' ? [['name', 'name'], ['dataFormat', labels.format], ['rate', labels.rate],
|
|
186
|
+
['sampleCount', labels.samples], ['md5ext', labels.file]] as const :
|
|
187
|
+
(element.properties ?? []).map(key => [key, projectPropertyLabel(key, locale)] as const);
|
|
188
|
+
return <section className="twgit-element-view" aria-label={elementTitle(element, target, labels, locale)}>
|
|
189
|
+
<h3>{target && <span>{target.isStage ? labels.stage : target.name} · </span>}{elementTitle(element, target, labels, locale)}</h3>
|
|
190
|
+
{element.kind === 'code' && target && renderBlock && <ProjectSourceComparison before={changes.sources.before}
|
|
191
|
+
after={changes.sources.after} renderBlock={renderBlock} locale={locale} loadAsset={changes.loadAsset}
|
|
192
|
+
target={{isStage: target.isStage, name: target.name}}/>}
|
|
193
|
+
{element.kind === 'costumes' && <div className="twgit-element-pictures">
|
|
194
|
+
{(['before', 'after'] as const).map(version => <Picture key={version} md5ext={element.md5ext?.[version]} version={version}
|
|
195
|
+
loadAsset={changes.loadAsset} labels={labels} title={element[version]?.name === undefined ? labels[version] : `${labels[version]} · ${String(element[version]!.name)}`}/>)}
|
|
196
|
+
</div>}
|
|
197
|
+
{element.kind !== 'code' && element.kind !== 'costumes' && <table className="twgit-element-values">
|
|
198
|
+
<thead><tr><th>{labels.property}</th><th>{labels.before}</th><th>{labels.after}</th></tr></thead>
|
|
199
|
+
<tbody>{rows.map(([key, name]) => <tr key={key}>
|
|
200
|
+
<th scope="row">{name}</th>
|
|
201
|
+
<td>{record('before') ? show(record('before')![key]) : labels.absent}</td>
|
|
202
|
+
<td>{record('after') ? show(record('after')![key]) : labels.absent}</td>
|
|
203
|
+
</tr>)}</tbody>
|
|
204
|
+
</table>}
|
|
205
|
+
<details className="twgit-element-files"><summary>{labels.files}</summary>
|
|
206
|
+
<ul>{element.files.map(file => <li key={file.group + ':' + file.path}><button className="twgit-file" type="button"
|
|
207
|
+
onClick={() => onSelectFile(file)}>{file.path} <small>{labels.groups[file.group]}</small></button></li>)}</ul>
|
|
208
|
+
</details>
|
|
209
|
+
</section>;
|
|
210
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type {ChangeGroup} from './repository.js';
|
|
2
|
+
import {equal, isRecord, targetKey} from './project-source-patch.js';
|
|
3
|
+
import type {ProjectSource, ProjectTarget} from './project-source-patch.js';
|
|
4
|
+
import {compareProjectForReview} from './project-source-review-model.js';
|
|
5
|
+
|
|
6
|
+
/** One changed file of the repository, with the Git group it was listed under. A path can appear in several groups. */
|
|
7
|
+
export interface ChangedFile {
|
|
8
|
+
path: string;
|
|
9
|
+
group: ChangeGroup;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ProjectElementKind = 'code' | 'costumes' | 'sounds' | 'data' | 'settings';
|
|
13
|
+
export type ProjectChangeStatus = 'added' | 'removed' | 'updated';
|
|
14
|
+
|
|
15
|
+
/** One row of the project-shaped change list: something a person recognizes in the Editor. */
|
|
16
|
+
export interface ProjectElementChange {
|
|
17
|
+
/** Stable within one list, for React keys and selection. */
|
|
18
|
+
key: string;
|
|
19
|
+
kind: ProjectElementKind;
|
|
20
|
+
status: ProjectChangeStatus;
|
|
21
|
+
/** Costume or sound name; absent for code, data and settings rows. */
|
|
22
|
+
name?: string;
|
|
23
|
+
/** The costume or sound entry in each version, for costume and sound rows. */
|
|
24
|
+
before?: Record<string, unknown>;
|
|
25
|
+
after?: Record<string, unknown>;
|
|
26
|
+
/** Asset file names (`md5ext`) of each version, for costume and sound rows. */
|
|
27
|
+
md5ext?: {before?: string; after?: string};
|
|
28
|
+
/**
|
|
29
|
+
* Changed property keys, for data and settings rows (for example `variables`, `x`, `visible`), and for
|
|
30
|
+
* project-level rows (`monitors`, `extensions`, `meta`).
|
|
31
|
+
*/
|
|
32
|
+
properties?: string[];
|
|
33
|
+
/** Number of changed code items (as counted by the review comparison), for code rows. */
|
|
34
|
+
scripts?: number;
|
|
35
|
+
/** The changed repository files behind this row, so the details view can show them with their Git groups. */
|
|
36
|
+
files: ChangedFile[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProjectTargetChanges {
|
|
40
|
+
key: string;
|
|
41
|
+
name: string;
|
|
42
|
+
isStage: boolean;
|
|
43
|
+
status: ProjectChangeStatus;
|
|
44
|
+
before?: ProjectTarget;
|
|
45
|
+
after?: ProjectTarget;
|
|
46
|
+
/** In the order code, costumes, sounds, data, settings; costumes and sounds in their order in the target. */
|
|
47
|
+
elements: ProjectElementChange[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ProjectChangeList {
|
|
51
|
+
/** The stage first, then sprites in project order; removed targets after the existing ones. */
|
|
52
|
+
targets: ProjectTargetChanges[];
|
|
53
|
+
/** Project-level changes outside the targets (monitors, extensions, meta and other top-level keys). */
|
|
54
|
+
project: ProjectElementChange[];
|
|
55
|
+
/** Changed files that belong to no element: sb3-source.json, embedded-extensions.json, unreferenced assets and the rest. */
|
|
56
|
+
other: ChangedFile[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface OrganizeProjectChangesInput {
|
|
60
|
+
/** The project folder relative to the repository root, `''` for the root. */
|
|
61
|
+
folder: string;
|
|
62
|
+
/** The project data of each version, `null` where the version has no project file. */
|
|
63
|
+
before: ProjectSource | null;
|
|
64
|
+
after: ProjectSource | null;
|
|
65
|
+
/** The assets directory of each version, relative to the folder (from sb3-source.json; `assets` by default). */
|
|
66
|
+
assetsDirectory: {before: string; after: string};
|
|
67
|
+
/** Every changed file in the folder, from all Git groups. */
|
|
68
|
+
files: readonly ChangedFile[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const targetDataKeys = ['variables', 'lists', 'broadcasts'] as const;
|
|
72
|
+
const nonSettingKeys = new Set(['blocks', 'costumes', 'sounds', ...targetDataKeys]);
|
|
73
|
+
|
|
74
|
+
const propertyLabels = new Map<string, readonly [string, string]>([
|
|
75
|
+
['variables', ['変数', 'Variables']], ['lists', ['リスト', 'Lists']], ['broadcasts', ['メッセージ', 'Broadcast messages']],
|
|
76
|
+
['costumes', ['コスチューム・背景', 'Costumes and backdrops']], ['sounds', ['音', 'Sounds']], ['blocks', ['コード', 'Code']],
|
|
77
|
+
['x', ['X 座標', 'X position']], ['y', ['Y 座標', 'Y position']], ['size', ['大きさ', 'Size']],
|
|
78
|
+
['direction', ['向き', 'Direction']], ['visible', ['表示状態', 'Visibility']],
|
|
79
|
+
['currentCostume', ['表示するコスチューム', 'Selected costume']], ['layerOrder', ['重なり順', 'Layer order']],
|
|
80
|
+
['volume', ['音量', 'Volume']], ['comments', ['コメント', 'Comments']], ['draggable', ['ドラッグ可否', 'Draggable']],
|
|
81
|
+
['rotationStyle', ['回転方法', 'Rotation style']], ['tempo', ['テンポ', 'Tempo']],
|
|
82
|
+
['videoTransparency', ['ビデオの透明度', 'Video transparency']], ['videoState', ['ビデオの状態', 'Video state']],
|
|
83
|
+
['textToSpeechLanguage', ['音声合成の言語', 'Text to speech language']], ['monitors', ['モニター', 'Monitors']],
|
|
84
|
+
['extensions', ['拡張機能', 'Extensions']], ['meta', ['作品の情報', 'Project metadata']]
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** The Japanese or English name of a project or target property key; unknown keys are returned as they are. */
|
|
88
|
+
export function projectPropertyLabel(key: string, locale = 'en'): string {
|
|
89
|
+
return propertyLabels.get(key)?.[locale.startsWith('ja') ? 0 : 1] ?? key;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The asset file name of a costume or sound entry: `md5ext`, or `assetId.dataFormat`; `''` when it has neither. */
|
|
93
|
+
export function assetFileName(entry: Record<string, unknown>): string {
|
|
94
|
+
if (typeof entry.md5ext === 'string') return entry.md5ext;
|
|
95
|
+
return typeof entry.assetId === 'string' && typeof entry.dataFormat === 'string' ? entry.assetId + '.' + entry.dataFormat : '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function joinPath(...parts: string[]) {
|
|
99
|
+
return parts.flatMap(part => part.split('/')).filter(part => part !== '' && part !== '.').join('/');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function statusOf(before: unknown, after: unknown): ProjectChangeStatus {
|
|
103
|
+
return before === undefined ? 'added' : after === undefined ? 'removed' : 'updated';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function entriesOf(target: ProjectTarget | undefined, kind: 'costumes' | 'sounds') {
|
|
107
|
+
const list = (target as Record<string, unknown> | undefined)?.[kind];
|
|
108
|
+
return Array.isArray(list) ? list.filter(isRecord) : [];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function nameOf(entry: Record<string, unknown>) {
|
|
112
|
+
return typeof entry.name === 'string' ? entry.name : '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Pairs the entries of two versions by name (repeated names pair in order): after's order first, then removed ones. */
|
|
116
|
+
function pairEntries(before: Record<string, unknown>[], after: Record<string, unknown>[]) {
|
|
117
|
+
const remaining = new Map<string, Record<string, unknown>[]>();
|
|
118
|
+
for (const entry of before) {
|
|
119
|
+
const queue = remaining.get(nameOf(entry));
|
|
120
|
+
if (queue) queue.push(entry);
|
|
121
|
+
else remaining.set(nameOf(entry), [entry]);
|
|
122
|
+
}
|
|
123
|
+
const pairs: {name: string; before?: Record<string, unknown>; after?: Record<string, unknown>}[] = [];
|
|
124
|
+
for (const entry of after) pairs.push({name: nameOf(entry), before: remaining.get(nameOf(entry))?.shift(), after: entry});
|
|
125
|
+
for (const entry of before) {
|
|
126
|
+
const queue = remaining.get(nameOf(entry));
|
|
127
|
+
if (queue?.[0] === entry) pairs.push({name: nameOf(entry), before: queue.shift()});
|
|
128
|
+
}
|
|
129
|
+
return pairs;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function changedKeys(before: Record<string, unknown>, after: Record<string, unknown>, skip: (key: string) => boolean) {
|
|
133
|
+
return [...new Set([...Object.keys(after), ...Object.keys(before)])].filter(key => !skip(key) && !equal(before[key], after[key]));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Arranges the changed files of one project folder by stage, sprite and element. Pure; reads nothing. */
|
|
137
|
+
export function organizeProjectChanges(input: OrganizeProjectChangesInput): ProjectChangeList {
|
|
138
|
+
const {folder, files, assetsDirectory} = input;
|
|
139
|
+
const normalize = (project: ProjectSource | null): ProjectSource => project === null ? {targets: []} :
|
|
140
|
+
{...project, targets: project.targets.map(target => ({...target, blocks: target.blocks ?? {}}))};
|
|
141
|
+
const before = normalize(input.before), after = normalize(input.after);
|
|
142
|
+
const projectPath = joinPath(folder, 'project.source.json');
|
|
143
|
+
const projectFiles = files.filter(file => file.path === projectPath);
|
|
144
|
+
const explained = new Set<string>();
|
|
145
|
+
let projectUsed = false;
|
|
146
|
+
const useProject = () => {
|
|
147
|
+
projectUsed = true;
|
|
148
|
+
return [...projectFiles];
|
|
149
|
+
};
|
|
150
|
+
const filesAt = (paths: (string | undefined)[]) => {
|
|
151
|
+
const wanted = new Set(paths.filter((path): path is string => path !== undefined));
|
|
152
|
+
const found = files.filter(file => wanted.has(file.path));
|
|
153
|
+
for (const file of found) explained.add(file.path);
|
|
154
|
+
return found;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const review = compareProjectForReview(before, after);
|
|
158
|
+
const scripts = new Map(review.targets.map(target => [targetKey((target.after ?? target.before)!), target.scripts.length]));
|
|
159
|
+
const oldTargets = new Map(before.targets.map(target => [targetKey(target), target]));
|
|
160
|
+
const newTargets = new Map(after.targets.map(target => [targetKey(target), target]));
|
|
161
|
+
const keys = [...new Set([...after.targets, ...before.targets].map(targetKey))];
|
|
162
|
+
keys.sort((a, b) => Number(b === 'stage') - Number(a === 'stage'));
|
|
163
|
+
|
|
164
|
+
const targets: ProjectTargetChanges[] = [];
|
|
165
|
+
for (const key of keys) {
|
|
166
|
+
const a = oldTargets.get(key), b = newTargets.get(key);
|
|
167
|
+
const status = statusOf(a, b);
|
|
168
|
+
const elements: ProjectElementChange[] = [];
|
|
169
|
+
const count = scripts.get(key) ?? 0;
|
|
170
|
+
if (count) {
|
|
171
|
+
elements.push({key: JSON.stringify([key, 'code', '']), kind: 'code', status, scripts: count, files: useProject()});
|
|
172
|
+
}
|
|
173
|
+
for (const kind of ['costumes', 'sounds'] as const) {
|
|
174
|
+
const seen = new Map<string, number>();
|
|
175
|
+
for (const pair of pairEntries(entriesOf(a, kind), entriesOf(b, kind))) {
|
|
176
|
+
const md5ext = {before: pair.before && assetFileName(pair.before) || undefined, after: pair.after && assetFileName(pair.after) || undefined};
|
|
177
|
+
const differs = !equal(pair.before, pair.after);
|
|
178
|
+
const assets = filesAt([md5ext.before && joinPath(folder, assetsDirectory.before, md5ext.before),
|
|
179
|
+
md5ext.after && joinPath(folder, assetsDirectory.after, md5ext.after)]);
|
|
180
|
+
if (!differs && !assets.length) continue;
|
|
181
|
+
const repeat = seen.get(pair.name) ?? 0;
|
|
182
|
+
seen.set(pair.name, repeat + 1);
|
|
183
|
+
elements.push({
|
|
184
|
+
key: JSON.stringify(repeat ? [key, kind, pair.name, repeat] : [key, kind, pair.name]), kind, status: statusOf(pair.before, pair.after),
|
|
185
|
+
name: pair.name, before: pair.before, after: pair.after, md5ext, files: [...(differs ? useProject() : []), ...assets]
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (a && b) {
|
|
190
|
+
const oldFields = a as unknown as Record<string, unknown>, newFields = b as unknown as Record<string, unknown>;
|
|
191
|
+
const data = changedKeys(oldFields, newFields, property => !(targetDataKeys as readonly string[]).includes(property));
|
|
192
|
+
if (data.length) elements.push({key: JSON.stringify([key, 'data', '']), kind: 'data', status, properties: data, files: useProject()});
|
|
193
|
+
const settings = changedKeys(oldFields, newFields, property => nonSettingKeys.has(property));
|
|
194
|
+
if (settings.length) {
|
|
195
|
+
elements.push({key: JSON.stringify([key, 'settings', '']), kind: 'settings', status, properties: settings, files: useProject()});
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
projectUsed = true;
|
|
199
|
+
}
|
|
200
|
+
if (status !== 'updated' || elements.length) {
|
|
201
|
+
const {name, isStage} = (b ?? a)!;
|
|
202
|
+
targets.push({key, name, isStage, status, before: a, after: b, elements});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const project = changedKeys(before, after, key => key === 'targets').map((property): ProjectElementChange => ({
|
|
207
|
+
key: JSON.stringify(['project', 'settings', property]), kind: 'settings', status: statusOf(before[property], after[property]),
|
|
208
|
+
properties: [property], files: useProject()
|
|
209
|
+
}));
|
|
210
|
+
if (projectUsed) explained.add(projectPath);
|
|
211
|
+
return {targets, project, other: files.filter(file => !explained.has(file.path))};
|
|
212
|
+
}
|
|
@@ -16,6 +16,9 @@ export type {ProjectBlockLocation, ProjectBlockLocator, ScratchBlockLocationWork
|
|
|
16
16
|
export type {BlockPart} from './project-source-patch.js';
|
|
17
17
|
export {createProjectSourceDiff} from './project-source-patch.js';
|
|
18
18
|
export {currentCostume} from './project-assets.js';
|
|
19
|
+
export {assetFileName, organizeProjectChanges, projectPropertyLabel} from './project-change-model.js';
|
|
20
|
+
export type {ChangedFile, OrganizeProjectChangesInput, ProjectChangeList, ProjectChangeStatus, ProjectElementChange, ProjectElementKind,
|
|
21
|
+
ProjectTargetChanges} from './project-change-model.js';
|
|
19
22
|
export type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
20
23
|
export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
|
|
21
24
|
export type {BlockPreviewView, BlockPreviewViewport, ProjectBlockRenderer, ProjectBlockRendering, ScratchBlockDiffRendererOptions,
|
|
@@ -168,27 +168,34 @@ export interface ProjectSourceComparisonProps {
|
|
|
168
168
|
fallback?: React.ReactNode;
|
|
169
169
|
/** Reads the images of both versions for thumbnails. Without it, stages and sprites are shown by name. */
|
|
170
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};
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
/** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
|
|
174
|
-
export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset}: ProjectSourceComparisonProps) {
|
|
176
|
+
export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset, target}: ProjectSourceComparisonProps) {
|
|
175
177
|
const labels = locale.startsWith('ja') ? ja : en;
|
|
178
|
+
const only = target && JSON.stringify([target.isStage, target.isStage || target.name]);
|
|
176
179
|
const comparison = useMemo(() => {
|
|
180
|
+
let result;
|
|
177
181
|
try {
|
|
178
|
-
|
|
182
|
+
result = compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
|
|
179
183
|
} catch {
|
|
180
184
|
return null;
|
|
181
185
|
}
|
|
182
|
-
|
|
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]);
|
|
183
190
|
if (!comparison) return fallback === undefined ? <p role="alert">{labels.invalid}</p> :
|
|
184
191
|
<><p role="alert" className="twgit-review-unreadable">{labels.unreadable}</p>{fallback}</>;
|
|
185
192
|
return <section className="twgit-block-diff twgit-review twgit-review-comparison" aria-label={labels.comparison}>
|
|
186
|
-
<header className="twgit-review-overview">
|
|
193
|
+
{!only && <header className="twgit-review-overview">
|
|
187
194
|
<dl>
|
|
188
195
|
<div><dt>{labels.targets}</dt><dd>{comparison.targets.length}</dd></div>
|
|
189
196
|
<div><dt>{labels.scripts}</dt><dd>{countScripts(comparison)}</dd></div>
|
|
190
197
|
</dl>
|
|
191
|
-
</header>
|
|
198
|
+
</header>}
|
|
192
199
|
<ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}/>
|
|
193
200
|
</section>;
|
|
194
201
|
}
|
package/src/repository.ts
CHANGED
|
@@ -28,6 +28,12 @@ export interface Commit {
|
|
|
28
28
|
interface Entry {oid: string; mode: number}
|
|
29
29
|
/** Where one version of a file is read from: a commit or tree, the index, or the working tree. */
|
|
30
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;
|
|
31
37
|
type Metadata = Map<string, Uint8Array | null>;
|
|
32
38
|
export interface Snapshot {
|
|
33
39
|
branch: string | undefined;
|
|
@@ -289,13 +295,21 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
289
295
|
const {blob} = await git.readBlob({...options, cache, oid: entry.oid});
|
|
290
296
|
return blob.byteLength <= limit ? blob : null;
|
|
291
297
|
};
|
|
292
|
-
const
|
|
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) => {
|
|
293
308
|
if (signal.aborted) return null;
|
|
294
309
|
fs.clearReads();
|
|
295
310
|
await assertSnapshot(snapshot);
|
|
296
|
-
const source = await
|
|
311
|
+
const source = await resolve();
|
|
297
312
|
if (!source) return null;
|
|
298
|
-
const folder = pathParts(change.path).slice(0, -1);
|
|
299
313
|
const settings = await readVersion(source, [...folder, 'sb3-source.json'].join('/'), folderSettingsLimit);
|
|
300
314
|
const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
|
|
301
315
|
if (!directory || signal.aborted) return null;
|
|
@@ -303,6 +317,30 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
303
317
|
await fs.checkReads();
|
|
304
318
|
return bytes && !signal.aborted ? new Blob([bytes as Uint8Array<ArrayBuffer>]) : null;
|
|
305
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
|
+
};
|
|
306
344
|
const directoryAt = async (path: string) => {
|
|
307
345
|
let directory = root;
|
|
308
346
|
for (const part of pathParts(path)) directory = await directory.getDirectoryHandle(part);
|
|
@@ -358,7 +396,18 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
358
396
|
*/
|
|
359
397
|
assetLoader: (snapshot: Snapshot, change: FileChange): ProjectAssetLoader => ({version, md5ext, signal}) =>
|
|
360
398
|
!isAssetName(md5ext) || signal.aborted ? Promise.resolve(null) :
|
|
361
|
-
serial(() => readAsset(snapshot, change, version, md5ext, signal)).catch(() => 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)
|
|
362
411
|
};
|
|
363
412
|
}
|
|
364
413
|
export type Repository = Awaited<ReturnType<typeof openRepository>>;
|
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; }
|