@kubohiroya/turbowarp-plus-editor-git 0.8.0 → 0.10.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/src/messages.ts CHANGED
@@ -7,9 +7,9 @@ export const messages = {
7
7
  open: 'Open This Project', reload: 'Reload Project', up: 'Parent folder', browse: 'Choose a project inside this repository',
8
8
  replace: 'Replace the open project and discard its unsaved changes?',
9
9
  dirty: 'The Editor has unsaved changes.', saved: 'The Editor has no unsaved changes.',
10
- staged: 'Staged', unstaged: 'Unstaged', untracked: 'Untracked', commit: 'Commit changes',
11
- history: 'History', worktree: 'Working tree', clean: 'No changes in the selected folder.',
12
- branches: 'Local branches', unborn: 'No commits yet', detached: 'Detached HEAD', select: 'Select a file to view its diff.',
10
+ staged: 'Changes in the next record', unstaged: 'Saved, not yet recorded', untracked: 'New files never recorded', commit: 'Changes in this record',
11
+ history: 'Records (commits)', worktree: 'Saved files', clean: 'No saved changes in the selected folder.',
12
+ branches: 'Local branches', unborn: 'No records yet', detached: 'Detached HEAD', select: 'Select a file to view its diff.',
13
13
  binary: 'Binary asset', large: 'Text preview exceeds its display limit.', bytes: 'bytes',
14
14
  mode: 'Git file mode', permissions: 'Working-tree executable permission changes are unavailable in the browser.',
15
15
  unchanged: 'The text is unchanged.', loading: 'Reading repository…',
@@ -17,16 +17,20 @@ export const messages = {
17
17
  changed: 'Files or Git state changed while reading. Refresh to read the current state.',
18
18
  outdated: 'The repository changed outside the Editor. Refresh to read the current state.',
19
19
  unsupported: 'This repository contains an entry the browser cannot represent.',
20
- pullUnsafe: 'Stop the project and clear unsaved or local Git changes before pulling.',
21
- diverged: 'Pull requires a fast-forward. Your local branch and files are intact.',
20
+ pullUnsafe: 'Stop the project, and save or undo unsaved edits and unrecorded changes, before getting changes from GitHub (pull).',
21
+ diverged: 'This branch and GitHub\'s have both gained records, so they cannot be combined here (pull only fast-forwards). Your branch and files are unchanged.',
22
22
  session: 'Reconnect GitHub for this repository and requested permission.',
23
- pushUnknown: 'The push result is unknown. Fetch and check the remote branch before retrying.',
24
- pushRefresh: 'The push succeeded. Fetch to refresh the local tracking branch.',
25
- rollbackFailed: 'Git could not restore all local changes. Check the working tree and Git status.',
26
- remoteFailed: 'The Git operation failed. Check the selected connection and retry.',
27
- remoteDone: 'Git operation completed.',
28
- pulledProject: 'Pull completed. Use Reload Project to open the updated files.',
29
- confirmPush: 'Push the current branch to its GitHub origin?',
23
+ pushUnknown: 'It is unknown whether GitHub received the records. Check GitHub (fetch) before sending again.',
24
+ pushRefresh: 'GitHub received the records. Check GitHub (fetch) to update the status.',
25
+ rollbackFailed: 'Some local changes could not be restored. Check the files and the Git details.',
26
+ remoteFailed: 'Could not complete the GitHub operation. Check the connection and try again.',
27
+ remoteDone: 'The GitHub operation finished.',
28
+ pulledProject: 'Got the changes from GitHub. Use Reload Project to open the updated files.',
29
+ confirmPush: 'Send the records on this branch to GitHub (push)?',
30
+ details: 'Git details', branch: 'Branch', head: 'HEAD', groups: 'Git groups', added: 'Added', modified: 'Changed', deleted: 'Deleted',
31
+ gitStaged: 'Staged', gitUnstaged: 'Unstaged', gitUntracked: 'Untracked',
32
+ upstreamSame: 'Same as GitHub', upstreamAhead: 'Ahead of GitHub', upstreamBehind: 'Behind GitHub', upstreamDiverged: 'Diverged from GitHub',
33
+ upstreamUnknown: 'Not checked with GitHub yet', records: 'records', checkedAt: 'as of the last check',
30
34
  failed: 'Could not read the repository. Check folder access and refresh.'
31
35
  },
32
36
  ja: {
@@ -35,11 +39,11 @@ export const messages = {
35
39
  empty: '既存の Git リポジトリを選び、ファイルの状態と履歴を確認できます。',
36
40
  repository: 'リポジトリ', project: '作品フォルダ', none: '作品は未選択', root: 'リポジトリルート',
37
41
  open: 'この作品を開く', reload: '作品を再読込', up: '上のフォルダ', browse: 'リポジトリ内の作品を選択',
38
- replace: '未保存の変更を破棄して、別の作品を開きますか?',
39
- dirty: 'Editor に未保存の変更があります。', saved: 'Editor に未保存の変更はありません。',
40
- staged: 'ステージ済み', unstaged: '未ステージ', untracked: '未追跡', commit: 'コミットの変更',
41
- history: '履歴', worktree: '作業ツリー', clean: '対象フォルダに変更はありません。',
42
- branches: 'ローカルブランチ', unborn: 'コミットはまだありません', detached: 'ブランチ未選択の HEAD', select: 'ファイルを選ぶと差分を表示します。',
42
+ replace: '保存していない編集を破棄して、別の作品を開きますか?',
43
+ dirty: 'Editor に保存していない編集があります。', saved: 'Editor に保存していない編集はありません。',
44
+ staged: '次の記録に入る変更', unstaged: '保存したが、まだ記録していない変更', untracked: 'まだ記録したことのない新しいファイル', commit: 'この記録の変更',
45
+ history: '記録(コミット)の履歴', worktree: '保存したファイル', clean: '対象フォルダに、保存した変更はありません。',
46
+ branches: 'ローカルブランチ', unborn: 'まだ記録がありません', detached: 'ブランチ未選択の HEAD', select: 'ファイルを選ぶと差分を表示します。',
43
47
  binary: 'バイナリアセット', large: 'テキスト差分の表示上限を超えています。', bytes: 'バイト',
44
48
  mode: 'Git のファイルモード', permissions: '作業ツリーの実行権限の変更はブラウザーから確認できません。',
45
49
  unchanged: 'テキストに変更はありません。', loading: 'リポジトリを読込中…',
@@ -47,16 +51,20 @@ export const messages = {
47
51
  changed: '読込中にファイルまたは Git の状態が変わりました。再取得してください。',
48
52
  outdated: 'Editor の外でリポジトリが変更されました。再取得すると最新の状態を読み込みます。',
49
53
  unsupported: 'ブラウザーで扱えない種類の Git エントリーがあります。',
50
- pullUnsafe: 'pull の前に実行を止め、未保存編集とローカルの Git 変更を解消してください。',
51
- diverged: 'fast-forward できません。ローカルブランチとファイルは保持されています。',
54
+ pullUnsafe: 'GitHub から取り込む前に、実行を止め、保存していない編集とまだ記録していない変更をなくしてください。',
55
+ diverged: 'このブランチと GitHub の両方に新しい記録があるため、ここでは取り込めません(pull は fast-forward のみ)。ブランチとファイルはそのままです。',
52
56
  session: 'このリポジトリと操作に必要な権限で GitHub に接続してください。',
53
- pushUnknown: 'push の結果が不明です。再試行する前に fetch でリモートブランチを確認してください。',
54
- pushRefresh: 'push は成功しました。fetch でローカルの追跡ブランチを更新してください。',
55
- rollbackFailed: 'Git がローカルの変更をすべて復元できませんでした。作業ツリーと Git の状態を確認してください。',
56
- remoteFailed: 'Git 操作に失敗しました。選択中の接続を確認して再試行してください。',
57
- remoteDone: 'Git 操作が完了しました。',
58
- pulledProject: 'pull が完了しました。「作品を再読込」で更新済みのファイルを開けます。',
59
- confirmPush: '現在のブランチを GitHub の origin に push しますか?',
57
+ pushUnknown: 'GitHub が記録を受け取ったか分かりません。送り直す前に「GitHub と確かめる」を使ってください。',
58
+ pushRefresh: 'GitHub が記録を受け取りました。「GitHub と確かめる」で状態を更新してください。',
59
+ rollbackFailed: '一部のローカルの変更を元に戻せませんでした。ファイルと Git の詳細を確認してください。',
60
+ remoteFailed: 'GitHub との操作を完了できませんでした。接続を確認して、もう一度試してください。',
61
+ remoteDone: 'GitHub との操作が終わりました。',
62
+ pulledProject: 'GitHub から取り込みました。「作品を再読込」で更新したファイルを開けます。',
63
+ confirmPush: 'このブランチの記録を GitHub に送りますか(push)?',
64
+ details: 'Git の詳細', branch: 'ブランチ', head: 'HEAD', groups: 'Git の区分', added: '追加', modified: '変更', deleted: '削除',
65
+ gitStaged: 'ステージ済み (staged)', gitUnstaged: '未ステージ (unstaged)', gitUntracked: '未追跡 (untracked)',
66
+ upstreamSame: 'GitHub と同じ', upstreamAhead: 'GitHub より進んでいる', upstreamBehind: 'GitHub より遅れている', upstreamDiverged: 'GitHub と分かれている',
67
+ upstreamUnknown: 'まだ GitHub と確かめていない', records: '件', checkedAt: '最後に確かめた時点',
60
68
  failed: 'リポジトリを読み込めませんでした。フォルダへのアクセスを確認し、再取得してください。'
61
69
  }
62
70
  };
@@ -43,18 +43,24 @@ function readAsset(loadAsset: ProjectAssetLoader, version: ProjectVersion, md5ex
43
43
  return read;
44
44
  }
45
45
 
46
- /** Loads the current costume of one version of a target as an object URL for an image, or `null` while it is unavailable. */
47
- export function useCostumeImage(target: ProjectTarget | undefined, version: ProjectVersion, loadAsset?: ProjectAssetLoader): string | null {
48
- const costume = target && loadAsset ? currentCostume(target) : null;
49
- const key = costume ? version + ':' + costume.md5ext : '';
46
+ /** The image type of an asset file name, or `undefined` for sounds and anything that is not an image. */
47
+ export function assetImageType(md5ext: string): string | undefined {
48
+ const match = /^[0-9A-Za-z_-]{1,128}\.([0-9A-Za-z]{1,8})$/.exec(md5ext);
49
+ return match ? imageTypes[match[1].toLowerCase()] : undefined;
50
+ }
51
+
52
+ /** Loads one image asset of one version as an object URL, or `null` while it is unavailable or not an image. */
53
+ export function useAssetImage(md5ext: string | undefined, version: ProjectVersion, loadAsset?: ProjectAssetLoader): string | null {
54
+ const type = md5ext ? assetImageType(md5ext) : undefined;
55
+ const key = md5ext && type && loadAsset ? version + ':' + md5ext : '';
50
56
  const [image, setImage] = useState<{key: string; url: string}>();
51
57
  useEffect(() => {
52
- if (!costume || !loadAsset) return;
58
+ if (!key || !loadAsset) return;
53
59
  let active = true;
54
60
  let url: string | undefined;
55
- void readAsset(loadAsset, version, costume.md5ext).then(blob => {
61
+ void readAsset(loadAsset, version, md5ext!).then(blob => {
56
62
  if (!blob || !active) return;
57
- url = URL.createObjectURL(new Blob([blob], {type: costume.type}));
63
+ url = URL.createObjectURL(new Blob([blob], {type}));
58
64
  setImage({key, url});
59
65
  });
60
66
  return () => {
@@ -64,3 +70,9 @@ export function useCostumeImage(target: ProjectTarget | undefined, version: Proj
64
70
  }, [key, loadAsset]);
65
71
  return image?.key === key ? image.url : null;
66
72
  }
73
+
74
+ /** Loads the current costume of one version of a target as an object URL for an image, or `null` while it is unavailable. */
75
+ export function useCostumeImage(target: ProjectTarget | undefined, version: ProjectVersion, loadAsset?: ProjectAssetLoader): string | null {
76
+ const costume = target && loadAsset ? currentCostume(target) : null;
77
+ return useAssetImage(costume?.md5ext, version, loadAsset);
78
+ }
@@ -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: 'in the next record', unstaged: 'not yet recorded', untracked: 'new file', commit: 'in this record'} 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
- return compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
182
+ result = compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
179
183
  } catch {
180
184
  return null;
181
185
  }
182
- }, [before, after]);
186
+ if (!only) return result;
187
+ return {properties: [], targets: result.targets.filter(item => JSON.stringify([item.isStage, item.isStage || item.name]) === only)
188
+ .map(item => ({...item, properties: []}))};
189
+ }, [before, after, only]);
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
  }