@kubohiroya/turbowarp-plus-editor-git 0.7.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.
Files changed (36) hide show
  1. package/README.ja.md +22 -6
  2. package/README.md +9 -1
  3. package/dist/block-diff-preview.d.ts +18 -2
  4. package/dist/file-diff-view.d.ts +3 -1
  5. package/dist/index.js +359 -15
  6. package/dist/{libesm-OWRWzmu_.js → project-asset-paths-KV715791.js} +23 -1
  7. package/dist/project-asset-paths.d.ts +12 -0
  8. package/dist/project-assets.d.ts +23 -0
  9. package/dist/project-change-list.d.ts +81 -0
  10. package/dist/project-change-model.d.ts +73 -0
  11. package/dist/project-source-diff.d.ts +7 -1
  12. package/dist/project-source-diff.js +10 -7
  13. package/dist/project-source-review-model.d.ts +4 -1
  14. package/dist/project-source-review.d.ts +11 -2
  15. package/dist/{repository-DplMQiuo.js → repository-VBp-UNWc.js} +134 -3
  16. package/dist/repository.d.ts +34 -0
  17. package/dist/repository.js +1 -1
  18. package/dist/{scratch-block-diff-renderer-CLYoOIB2.js → scratch-block-diff-renderer-DZR8ep-T.js} +708 -90
  19. package/dist/scratch-block-diff-renderer.d.ts +69 -6
  20. package/dist/style.css +48 -2
  21. package/package.json +7 -2
  22. package/src/block-diff-preview.tsx +89 -8
  23. package/src/file-diff-view.tsx +4 -3
  24. package/src/github-pull-requests.ts +73 -6
  25. package/src/index.tsx +42 -12
  26. package/src/project-asset-paths.ts +33 -0
  27. package/src/project-assets.ts +78 -0
  28. package/src/project-change-list.tsx +210 -0
  29. package/src/project-change-model.ts +212 -0
  30. package/src/project-source-diff.css +31 -2
  31. package/src/project-source-diff.tsx +17 -7
  32. package/src/project-source-review-model.ts +4 -1
  33. package/src/project-source-review.tsx +67 -27
  34. package/src/repository.ts +107 -2
  35. package/src/scratch-block-diff-renderer.ts +129 -11
  36. package/src/style.css +17 -0
@@ -1,52 +1,6 @@
1
1
  import { n as init_objectSpread2, t as _objectSpread2 } from "./objectSpread2-Dkd68nJH.js";
2
- import { n as hasOnlyUnixLineEndings, r as hasOnlyWinLineEndings, t as createTwoFilesPatch } from "./libesm-OWRWzmu_.js";
2
+ import { a as createTwoFilesPatch, i as isAssetName, n as assetsDirectoryParts, o as hasOnlyUnixLineEndings, r as folderSettingsLimit, s as hasOnlyWinLineEndings, t as assetLimit } from "./project-asset-paths-KV715791.js";
3
3
  import React, { useEffect, useMemo, useRef, useState } from "react";
4
- // src/block-diff-preview.tsx
5
- init_objectSpread2();
6
- function BlockPreview({ side, title, parts, marks, renderBlock, withContext, labels, onLocate, locationStatus }) {
7
- const host = useRef(null);
8
- const [status, setStatus] = useState("loading");
9
- useEffect(() => {
10
- if (!side || !host.current) return;
11
- const container = host.current;
12
- const abort = new AbortController();
13
- let dispose;
14
- setStatus("loading");
15
- Promise.resolve().then(() => renderBlock(_objectSpread2(_objectSpread2({}, side), {}, {
16
- container,
17
- withContext,
18
- parts,
19
- marks,
20
- signal: abort.signal
21
- }))).then((cleanup) => {
22
- if (abort.signal.aborted) cleanup === null || cleanup === void 0 || cleanup();
23
- else {
24
- dispose = cleanup;
25
- setStatus("ready");
26
- }
27
- }, () => {
28
- if (!abort.signal.aborted) setStatus("failed");
29
- });
30
- return () => {
31
- abort.abort();
32
- dispose === null || dispose === void 0 || dispose();
33
- };
34
- }, [
35
- side,
36
- renderBlock,
37
- withContext,
38
- parts,
39
- marks
40
- ]);
41
- return /* @__PURE__ */ React.createElement("div", { className: "twgit-block-diff-side" }, /* @__PURE__ */ React.createElement("h4", null, title), !side && /* @__PURE__ */ React.createElement("p", null, labels.absent), side && status === "loading" && /* @__PURE__ */ React.createElement("p", { role: "status" }, labels.loading), side && status === "failed" && /* @__PURE__ */ React.createElement("p", { role: "status" }, labels.unavailable), /* @__PURE__ */ React.createElement("div", {
42
- ref: host,
43
- className: "twgit-block-diff-preview"
44
- }), side && onLocate && /* @__PURE__ */ React.createElement("button", {
45
- type: "button",
46
- "aria-label": `${title} · ${labels.locate}`,
47
- onClick: onLocate
48
- }, labels.locate), locationStatus && /* @__PURE__ */ React.createElement("p", { role: "status" }, labels[locationStatus]));
49
- }
50
4
  // node_modules/diff/libesm/patch/line-endings.js
51
5
  function unixToWin(patch) {
52
6
  if (Array.isArray(patch)) return patch.map((p) => unixToWin(p));
@@ -560,6 +514,7 @@ function applyStructuredPatch(source, patch, options = {}) {
560
514
  return resultLines.join("\n");
561
515
  }
562
516
  // \0@oxc-project+runtime@0.156.0/helpers/esm/objectWithoutPropertiesLoose.js
517
+ init_objectSpread2();
563
518
  function _objectWithoutPropertiesLoose(r, e) {
564
519
  if (null == r) return {};
565
520
  var t = {};
@@ -580,7 +535,6 @@ function _objectWithoutProperties(e, t) {
580
535
  return i;
581
536
  }
582
537
  // src/project-source-patch.ts
583
- init_objectSpread2();
584
538
  var _excluded = ["blocks"];
585
539
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
586
540
  function readProject(source) {
@@ -698,6 +652,211 @@ function compareProjectBlocks(before, after) {
698
652
  otherChanges: !equal(withoutBlocks(before), withoutBlocks(after))
699
653
  };
700
654
  }
655
+ // src/project-assets.ts
656
+ var imageTypes = {
657
+ svg: "image/svg+xml",
658
+ png: "image/png",
659
+ jpg: "image/jpeg",
660
+ jpeg: "image/jpeg",
661
+ gif: "image/gif",
662
+ bmp: "image/bmp",
663
+ webp: "image/webp"
664
+ };
665
+ /** The costume a sprite shows, or the backdrop the stage shows, when the project data names an image file for it. */
666
+ function currentCostume(target) {
667
+ const { costumes, currentCostume: index } = target;
668
+ if (!Array.isArray(costumes)) return null;
669
+ const costume = costumes[typeof index === "number" && Number.isInteger(index) && index >= 0 ? index : 0];
670
+ if (!isRecord(costume)) return null;
671
+ const md5ext = typeof costume.md5ext === "string" ? costume.md5ext : typeof costume.assetId === "string" && typeof costume.dataFormat === "string" ? costume.assetId + "." + costume.dataFormat : "";
672
+ const match = /^[0-9A-Za-z_-]{1,128}\.([0-9A-Za-z]{1,8})$/.exec(md5ext);
673
+ const type = match ? imageTypes[match[1].toLowerCase()] : void 0;
674
+ if (!type) return null;
675
+ return {
676
+ name: typeof costume.name === "string" ? costume.name : "",
677
+ md5ext,
678
+ type
679
+ };
680
+ }
681
+ var reads = /* @__PURE__ */ new WeakMap();
682
+ function readAsset(loadAsset, version, md5ext) {
683
+ let cache = reads.get(loadAsset);
684
+ if (!cache) reads.set(loadAsset, cache = /* @__PURE__ */ new Map());
685
+ const key = version + ":" + md5ext;
686
+ let read = cache.get(key);
687
+ if (!read) {
688
+ read = loadAsset({
689
+ version,
690
+ md5ext,
691
+ signal: new AbortController().signal
692
+ }).catch(() => null);
693
+ cache.set(key, read);
694
+ }
695
+ return read;
696
+ }
697
+ /** The image type of an asset file name, or `undefined` for sounds and anything that is not an image. */
698
+ function assetImageType(md5ext) {
699
+ const match = /^[0-9A-Za-z_-]{1,128}\.([0-9A-Za-z]{1,8})$/.exec(md5ext);
700
+ return match ? imageTypes[match[1].toLowerCase()] : void 0;
701
+ }
702
+ /** Loads one image asset of one version as an object URL, or `null` while it is unavailable or not an image. */
703
+ function useAssetImage(md5ext, version, loadAsset) {
704
+ const type = md5ext ? assetImageType(md5ext) : void 0;
705
+ const key = md5ext && type && loadAsset ? version + ":" + md5ext : "";
706
+ const [image, setImage] = useState();
707
+ useEffect(() => {
708
+ if (!key || !loadAsset) return;
709
+ let active = true;
710
+ let url;
711
+ readAsset(loadAsset, version, md5ext).then((blob) => {
712
+ if (!blob || !active) return;
713
+ url = URL.createObjectURL(new Blob([blob], { type }));
714
+ setImage({
715
+ key,
716
+ url
717
+ });
718
+ });
719
+ return () => {
720
+ active = false;
721
+ if (url) URL.revokeObjectURL(url);
722
+ };
723
+ }, [key, loadAsset]);
724
+ return (image === null || image === void 0 ? void 0 : image.key) === key ? image.url : null;
725
+ }
726
+ /** Loads the current costume of one version of a target as an object URL for an image, or `null` while it is unavailable. */
727
+ function useCostumeImage(target, version, loadAsset) {
728
+ const costume = target && loadAsset ? currentCostume(target) : null;
729
+ return useAssetImage(costume === null || costume === void 0 ? void 0 : costume.md5ext, version, loadAsset);
730
+ }
731
+ // src/block-diff-preview.tsx
732
+ init_objectSpread2();
733
+ function createPreviewSync() {
734
+ const members = /* @__PURE__ */ new Map();
735
+ let linked = true;
736
+ let moving = false;
737
+ const align = () => {
738
+ if (!linked || !members.size) return;
739
+ const views = [...members.keys()];
740
+ const fit = {
741
+ scale: Math.min(...views.map((view) => view.fit.scale)),
742
+ height: Math.max(...views.map((view) => view.fit.height))
743
+ };
744
+ for (const view of views) view.layout(fit);
745
+ };
746
+ return {
747
+ add(view) {
748
+ const stop = view.onChange(() => {
749
+ if (!linked || moving) return;
750
+ moving = true;
751
+ try {
752
+ const viewport = view.viewport();
753
+ for (const other of members.keys()) if (other !== view) other.setViewport(viewport);
754
+ } finally {
755
+ moving = false;
756
+ }
757
+ });
758
+ members.set(view, stop);
759
+ align();
760
+ return () => {
761
+ stop();
762
+ members.delete(view);
763
+ };
764
+ },
765
+ setLinked(value) {
766
+ if (linked === value) return;
767
+ linked = value;
768
+ align();
769
+ }
770
+ };
771
+ }
772
+ var linkLabels = {
773
+ en: "Move both versions together",
774
+ ja: "変更前と変更後を連動して動かす"
775
+ };
776
+ /** One sync group per comparison, with the checkbox that links or unlinks its previews. */
777
+ function usePreviewSync(locale) {
778
+ const sync = useMemo(createPreviewSync, []);
779
+ const [linked, setLinked] = useState(true);
780
+ useEffect(() => sync.setLinked(linked), [sync, linked]);
781
+ return {
782
+ sync,
783
+ toggle: /* @__PURE__ */ React.createElement("label", { className: "twgit-block-diff-link" }, /* @__PURE__ */ React.createElement("input", {
784
+ type: "checkbox",
785
+ checked: linked,
786
+ onChange: (event) => setLinked(event.currentTarget.checked)
787
+ }), " ", locale.startsWith("ja") ? linkLabels.ja : linkLabels.en)
788
+ };
789
+ }
790
+ var rendering = (result) => typeof result === "function" ? { release: result } : {
791
+ release: result && (() => result.dispose()),
792
+ view: result === null || result === void 0 ? void 0 : result.view
793
+ };
794
+ function BlockPreview({ side, title, parts, marks, renderBlock, withContext, labels, onLocate, locationStatus, version, loadAsset, sync }) {
795
+ const host = useRef(null);
796
+ const watermark = useRef(null);
797
+ const [status, setStatus] = useState("loading");
798
+ const thumbnail = !!(side && version && loadAsset && currentCostume(side.target));
799
+ const image = useCostumeImage(thumbnail ? side.target : void 0, version !== null && version !== void 0 ? version : "after", loadAsset);
800
+ useEffect(() => {
801
+ if (!side || !host.current) return;
802
+ const container = host.current;
803
+ const abort = new AbortController();
804
+ let dispose;
805
+ setStatus("loading");
806
+ Promise.resolve().then(() => {
807
+ var _watermark$current;
808
+ return renderBlock(_objectSpread2(_objectSpread2({}, side), {}, {
809
+ container,
810
+ withContext,
811
+ parts,
812
+ marks,
813
+ signal: abort.signal,
814
+ watermark: thumbnail ? (_watermark$current = watermark.current) !== null && _watermark$current !== void 0 ? _watermark$current : void 0 : void 0
815
+ }));
816
+ }).then((result) => {
817
+ const { release, view } = rendering(result);
818
+ if (abort.signal.aborted) {
819
+ release === null || release === void 0 || release();
820
+ return;
821
+ }
822
+ const leave = view && (sync === null || sync === void 0 ? void 0 : sync.add(view));
823
+ dispose = () => {
824
+ leave === null || leave === void 0 || leave();
825
+ release === null || release === void 0 || release();
826
+ };
827
+ setStatus("ready");
828
+ }, () => {
829
+ if (!abort.signal.aborted) setStatus("failed");
830
+ });
831
+ return () => {
832
+ abort.abort();
833
+ dispose === null || dispose === void 0 || dispose();
834
+ };
835
+ }, [
836
+ side,
837
+ renderBlock,
838
+ withContext,
839
+ parts,
840
+ marks,
841
+ sync,
842
+ thumbnail
843
+ ]);
844
+ return /* @__PURE__ */ React.createElement("div", { className: "twgit-block-diff-side" }, /* @__PURE__ */ React.createElement("h4", null, title), !side && /* @__PURE__ */ React.createElement("p", null, labels.absent), side && status === "loading" && /* @__PURE__ */ React.createElement("p", { role: "status" }, labels.loading), side && status === "failed" && /* @__PURE__ */ React.createElement("p", { role: "status" }, labels.unavailable), side && /* @__PURE__ */ React.createElement("div", {
845
+ ref: host,
846
+ className: "twgit-block-diff-preview"
847
+ }, thumbnail && /* @__PURE__ */ React.createElement("div", {
848
+ ref: watermark,
849
+ className: "twgit-block-diff-watermark",
850
+ "data-dockview-costume-thumbnail": ""
851
+ }, image && /* @__PURE__ */ React.createElement("img", {
852
+ src: image,
853
+ alt: ""
854
+ }))), side && onLocate && /* @__PURE__ */ React.createElement("button", {
855
+ type: "button",
856
+ "aria-label": `${title} · ${labels.locate}`,
857
+ onClick: onLocate
858
+ }, labels.locate), locationStatus && /* @__PURE__ */ React.createElement("p", { role: "status" }, labels[locationStatus]));
859
+ }
701
860
  // src/project-source-review-model.ts
702
861
  init_objectSpread2();
703
862
  function scriptRoots(target) {
@@ -807,7 +966,9 @@ function compareProjectForReview(before, after) {
807
966
  name,
808
967
  isStage,
809
968
  scripts,
810
- properties
969
+ properties,
970
+ before: a,
971
+ after: b
811
972
  });
812
973
  }
813
974
  }
@@ -817,24 +978,25 @@ function compareProjectForReview(before, after) {
817
978
  };
818
979
  }
819
980
  // src/project-source-review.tsx
981
+ init_objectSpread2();
820
982
  var ja = {
821
983
  review: "変更レビュー",
822
984
  fullDescription: "PR 本文を表示",
823
985
  checks: "確認してほしいこと",
824
986
  files: "変更ファイル",
825
987
  targets: "影響するステージ・スプライト",
826
- scripts: "スクリプトの比較",
988
+ scripts: "コードの変更",
989
+ scriptCount: (count) => `コードの変更 · ${count} か所`,
827
990
  reviewed: "確認済み",
828
- reviewedScripts: "スクリプト確認済み",
829
991
  before: "変更前",
830
992
  after: "変更後",
831
993
  updated: "変更",
832
994
  added: "追加",
833
995
  removed: "削除",
834
996
  reorganized: "再編",
835
- absent: "この版にはスクリプトがありません",
836
- loading: "スクリプトを描画中…",
837
- unavailable: "このスクリプトを描画できません。",
997
+ absent: "この版にはこのコードがありません",
998
+ loading: "コードを描画中…",
999
+ unavailable: "このコードを描画できません。",
838
1000
  related: "関連ファイル",
839
1001
  properties: "コード以外の変更",
840
1002
  invalid: "この作品の差分を読み込めません。",
@@ -842,7 +1004,7 @@ var ja = {
842
1004
  broadcast: "を受け取ったとき",
843
1005
  key: "キーが押されたとき",
844
1006
  click: "このスプライトが押されたとき",
845
- script: "スクリプト",
1007
+ script: "コード",
846
1008
  noChanges: "作品に内容の変更はありません。",
847
1009
  comparison: "作品の変更",
848
1010
  unreadable: "作品データとして読み込めないため、テキストの差分を表示します。"
@@ -853,18 +1015,18 @@ var en = {
853
1015
  checks: "Review checklist",
854
1016
  files: "Changed files",
855
1017
  targets: "Affected stages and sprites",
856
- scripts: "Script comparisons",
1018
+ scripts: "Code changes",
1019
+ scriptCount: (count) => `Code changes · ${count}`,
857
1020
  reviewed: "Reviewed",
858
- reviewedScripts: "Reviewed scripts",
859
1021
  before: "Before",
860
1022
  after: "After",
861
1023
  updated: "Changed",
862
1024
  added: "Added",
863
1025
  removed: "Removed",
864
1026
  reorganized: "Reorganized",
865
- absent: "No script in this version",
866
- loading: "Rendering script…",
867
- unavailable: "This script could not be rendered.",
1027
+ absent: "No code in this version",
1028
+ loading: "Rendering code…",
1029
+ unavailable: "This code could not be rendered.",
868
1030
  related: "Related file",
869
1031
  properties: "Changes outside the code",
870
1032
  invalid: "This project diff could not be read.",
@@ -872,7 +1034,7 @@ var en = {
872
1034
  broadcast: "received",
873
1035
  key: "When key pressed",
874
1036
  click: "When this sprite clicked",
875
- script: "Script",
1037
+ script: "Code",
876
1038
  noChanges: "No changes to the project contents.",
877
1039
  comparison: "Project changes",
878
1040
  unreadable: "This file could not be read as project data, so its text diff is shown."
@@ -906,8 +1068,9 @@ function scriptTitle(side, labels) {
906
1068
  }
907
1069
  return typeof block.opcode === "string" ? block.opcode : labels.script;
908
1070
  }
909
- function ScriptComparison({ script, labels, reviewed = false, setReviewed, renderBlock }) {
1071
+ function ScriptComparison({ script, labels, reviewed = false, setReviewed, renderBlock, loadAsset }) {
910
1072
  const [open, setOpen] = useState(false);
1073
+ const { sync, toggle } = usePreviewSync(labels === ja ? "ja" : "en");
911
1074
  const status = !script.before.length ? "added" : !script.after.length ? "removed" : script.before.length !== 1 || script.after.length !== 1 ? "reorganized" : "updated";
912
1075
  const beforeTitle = script.before[0] && scriptTitle(script.before[0], labels);
913
1076
  const afterTitle = script.after[0] && scriptTitle(script.after[0], labels);
@@ -922,13 +1085,16 @@ function ScriptComparison({ script, labels, reviewed = false, setReviewed, rende
922
1085
  marks: side.marks,
923
1086
  renderBlock,
924
1087
  withContext: true,
925
- labels
1088
+ labels,
1089
+ version,
1090
+ loadAsset,
1091
+ sync
926
1092
  })) : /* @__PURE__ */ React.createElement(BlockPreview, {
927
1093
  title: labels[version],
928
1094
  renderBlock,
929
1095
  withContext: true,
930
1096
  labels
931
- })))), setReviewed && /* @__PURE__ */ React.createElement("label", { className: "twgit-review-checked" }, /* @__PURE__ */ React.createElement("input", {
1097
+ })))), toggle, setReviewed && /* @__PURE__ */ React.createElement("label", { className: "twgit-review-checked" }, /* @__PURE__ */ React.createElement("input", {
932
1098
  type: "checkbox",
933
1099
  checked: reviewed,
934
1100
  onChange: (event) => setReviewed(event.currentTarget.checked)
@@ -940,12 +1106,41 @@ function propertyNames(keys, labels) {
940
1106
  return (_properties$get = (_properties$get2 = properties.get(key)) === null || _properties$get2 === void 0 ? void 0 : _properties$get2[labels === ja ? 0 : 1]) !== null && _properties$get !== void 0 ? _properties$get : key;
941
1107
  }).join(" · ");
942
1108
  }
943
- /** Lists the changed stages and sprites of one project file, each with its script comparisons. */
944
- function ComparisonTargets({ comparison, labels, renderBlock, review }) {
1109
+ /**
1110
+ * The current costume (the backdrop for the stage) of each version of a target that has one. A changed costume shows
1111
+ * before → after. Nothing is shown until an image is read, so the heading falls back to the name alone.
1112
+ */
1113
+ function TargetThumbnails({ target, labels, loadAsset }) {
1114
+ const images = {
1115
+ before: useCostumeImage(target.before, "before", loadAsset),
1116
+ after: useCostumeImage(target.after, "after", loadAsset)
1117
+ };
1118
+ const costumes = {
1119
+ before: target.before && currentCostume(target.before),
1120
+ after: target.after && currentCostume(target.after)
1121
+ };
1122
+ const changed = !!costumes.before && !!costumes.after && costumes.before.md5ext !== costumes.after.md5ext;
1123
+ const shown = (changed ? ["before", "after"] : [images.after ? "after" : "before"]).filter((version) => images[version]);
1124
+ if (!shown.length) return null;
1125
+ return /* @__PURE__ */ React.createElement("span", { className: "twgit-review-thumbnails" }, shown.map((version, index) => {
1126
+ var _costumes$version, _costumes$version$nam, _costumes$version2;
1127
+ return /* @__PURE__ */ React.createElement(React.Fragment, { key: version }, index > 0 && /* @__PURE__ */ React.createElement("span", { "aria-hidden": "true" }, "→"), /* @__PURE__ */ React.createElement("img", {
1128
+ src: images[version],
1129
+ title: ((_costumes$version = costumes[version]) === null || _costumes$version === void 0 ? void 0 : _costumes$version.name) || void 0,
1130
+ alt: changed ? `${labels[version]}: ${(_costumes$version$nam = (_costumes$version2 = costumes[version]) === null || _costumes$version2 === void 0 ? void 0 : _costumes$version2.name) !== null && _costumes$version$nam !== void 0 ? _costumes$version$nam : ""}` : ""
1131
+ }));
1132
+ }));
1133
+ }
1134
+ /** Lists the changed stages and sprites of one project file, each with its code comparisons. */
1135
+ function ComparisonTargets({ comparison, labels, renderBlock, loadAsset, review }) {
945
1136
  return /* @__PURE__ */ React.createElement(React.Fragment, null, comparison.properties.length > 0 && /* @__PURE__ */ React.createElement("p", null, labels.properties, " · ", propertyNames(comparison.properties, labels)), !comparison.targets.length && !comparison.properties.length && /* @__PURE__ */ React.createElement("p", null, labels.noChanges), comparison.targets.map((target) => /* @__PURE__ */ React.createElement("section", {
946
1137
  className: "twgit-review-target",
947
1138
  key: JSON.stringify([target.isStage, target.name])
948
- }, /* @__PURE__ */ React.createElement("h5", null, target.name, target.scripts.length > 0 && /* @__PURE__ */ React.createElement("small", null, labels.scripts, " · ", target.scripts.length)), target.properties.length > 0 && /* @__PURE__ */ React.createElement("p", null, labels.properties, " · ", propertyNames(target.properties, labels)), target.scripts.map((script) => {
1139
+ }, /* @__PURE__ */ React.createElement("h5", null, /* @__PURE__ */ React.createElement(TargetThumbnails, {
1140
+ target,
1141
+ labels,
1142
+ loadAsset
1143
+ }), target.name, target.scripts.length > 0 && /* @__PURE__ */ React.createElement("small", null, labels.scriptCount(target.scripts.length))), target.properties.length > 0 && /* @__PURE__ */ React.createElement("p", null, labels.properties, " · ", propertyNames(target.properties, labels)), target.scripts.map((script) => {
949
1144
  const key = JSON.stringify([
950
1145
  review === null || review === void 0 ? void 0 : review.file,
951
1146
  target.isStage,
@@ -957,22 +1152,34 @@ function ComparisonTargets({ comparison, labels, renderBlock, review }) {
957
1152
  script,
958
1153
  labels,
959
1154
  renderBlock,
1155
+ loadAsset,
960
1156
  reviewed: review === null || review === void 0 ? void 0 : review.keys.has(key),
961
1157
  setReviewed: review && ((value) => review.set(key, value))
962
1158
  });
963
1159
  }))));
964
1160
  }
965
1161
  var countScripts = (comparison) => comparison.targets.reduce((count, target) => count + target.scripts.length, 0);
966
- /** Compares two versions of a project.source.json by stage, sprite and script, as the pull request review does. */
967
- function ProjectSourceComparison({ before, after, renderBlock, locale = "en", fallback }) {
1162
+ /** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
1163
+ function ProjectSourceComparison({ before, after, renderBlock, locale = "en", fallback, loadAsset, target }) {
968
1164
  const labels = locale.startsWith("ja") ? ja : en;
1165
+ const only = target && JSON.stringify([target.isStage, target.isStage || target.name]);
969
1166
  const comparison = useMemo(() => {
1167
+ let result;
970
1168
  try {
971
- return compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
1169
+ result = compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
972
1170
  } catch (_unused) {
973
1171
  return null;
974
1172
  }
975
- }, [before, after]);
1173
+ if (!only) return result;
1174
+ return {
1175
+ properties: [],
1176
+ targets: result.targets.filter((item) => JSON.stringify([item.isStage, item.isStage || item.name]) === only).map((item) => _objectSpread2(_objectSpread2({}, item), {}, { properties: [] }))
1177
+ };
1178
+ }, [
1179
+ before,
1180
+ after,
1181
+ only
1182
+ ]);
976
1183
  if (!comparison) return fallback === void 0 ? /* @__PURE__ */ React.createElement("p", { role: "alert" }, labels.invalid) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("p", {
977
1184
  role: "alert",
978
1185
  className: "twgit-review-unreadable"
@@ -980,10 +1187,11 @@ function ProjectSourceComparison({ before, after, renderBlock, locale = "en", fa
980
1187
  return /* @__PURE__ */ React.createElement("section", {
981
1188
  className: "twgit-block-diff twgit-review twgit-review-comparison",
982
1189
  "aria-label": labels.comparison
983
- }, /* @__PURE__ */ React.createElement("header", { className: "twgit-review-overview" }, /* @__PURE__ */ React.createElement("dl", null, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.targets), /* @__PURE__ */ React.createElement("dd", null, comparison.targets.length)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.scripts), /* @__PURE__ */ React.createElement("dd", null, countScripts(comparison))))), /* @__PURE__ */ React.createElement(ComparisonTargets, {
1190
+ }, !only && /* @__PURE__ */ React.createElement("header", { className: "twgit-review-overview" }, /* @__PURE__ */ React.createElement("dl", null, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.targets), /* @__PURE__ */ React.createElement("dd", null, comparison.targets.length)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.scripts), /* @__PURE__ */ React.createElement("dd", null, countScripts(comparison))))), /* @__PURE__ */ React.createElement(ComparisonTargets, {
984
1191
  comparison,
985
1192
  labels,
986
- renderBlock
1193
+ renderBlock,
1194
+ loadAsset
987
1195
  }));
988
1196
  }
989
1197
  function ProjectSourceReview({ title, description, verification, files, renderBlock, locale = "en" }) {
@@ -1028,13 +1236,14 @@ function ProjectSourceReview({ title, description, verification, files, renderBl
1028
1236
  return /* @__PURE__ */ React.createElement("section", {
1029
1237
  className: "twgit-block-diff twgit-review",
1030
1238
  "aria-label": labels.review
1031
- }, /* @__PURE__ */ React.createElement("header", { className: "twgit-review-overview" }, /* @__PURE__ */ React.createElement("p", null, labels.review), /* @__PURE__ */ React.createElement("h2", null, title), excerpt && /* @__PURE__ */ React.createElement("p", { className: "twgit-review-description" }, excerpt), /* @__PURE__ */ React.createElement("dl", null, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.files), /* @__PURE__ */ React.createElement("dd", null, entries.length)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.targets), /* @__PURE__ */ React.createElement("dd", null, targets)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.scripts), /* @__PURE__ */ React.createElement("dd", null, scripts)), scripts > 0 && /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.reviewedScripts), /* @__PURE__ */ React.createElement("dd", null, reviewed.size, " / ", scripts))), longBody && /* @__PURE__ */ React.createElement("details", { className: "twgit-review-body" }, /* @__PURE__ */ React.createElement("summary", null, labels.fullDescription), /* @__PURE__ */ React.createElement("p", { className: "twgit-review-description" }, body)), !!(verification === null || verification === void 0 ? void 0 : verification.length) && /* @__PURE__ */ React.createElement("div", { className: "twgit-review-checklist" }, /* @__PURE__ */ React.createElement("h3", null, labels.checks), /* @__PURE__ */ React.createElement("ul", null, verification.map((item, index) => /* @__PURE__ */ React.createElement("li", { key: index }, item))))), /* @__PURE__ */ React.createElement("h3", { className: "twgit-review-files-title" }, labels.files), entries.map(({ file, comparison, invalid }) => /* @__PURE__ */ React.createElement("section", {
1239
+ }, /* @__PURE__ */ React.createElement("header", { className: "twgit-review-overview" }, /* @__PURE__ */ React.createElement("p", null, labels.review), /* @__PURE__ */ React.createElement("h2", null, title), excerpt && /* @__PURE__ */ React.createElement("p", { className: "twgit-review-description" }, excerpt), /* @__PURE__ */ React.createElement("dl", null, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.files), /* @__PURE__ */ React.createElement("dd", null, entries.length)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.targets), /* @__PURE__ */ React.createElement("dd", null, targets)), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.scripts), /* @__PURE__ */ React.createElement("dd", null, scripts)), scripts > 0 && /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("dt", null, labels.reviewed), /* @__PURE__ */ React.createElement("dd", null, reviewed.size, " / ", scripts))), longBody && /* @__PURE__ */ React.createElement("details", { className: "twgit-review-body" }, /* @__PURE__ */ React.createElement("summary", null, labels.fullDescription), /* @__PURE__ */ React.createElement("p", { className: "twgit-review-description" }, body)), !!(verification === null || verification === void 0 ? void 0 : verification.length) && /* @__PURE__ */ React.createElement("div", { className: "twgit-review-checklist" }, /* @__PURE__ */ React.createElement("h3", null, labels.checks), /* @__PURE__ */ React.createElement("ul", null, verification.map((item, index) => /* @__PURE__ */ React.createElement("li", { key: index }, item))))), /* @__PURE__ */ React.createElement("h3", { className: "twgit-review-files-title" }, labels.files), entries.map(({ file, comparison, invalid }) => /* @__PURE__ */ React.createElement("section", {
1032
1240
  key: file.path,
1033
1241
  className: "twgit-review-file"
1034
1242
  }, /* @__PURE__ */ React.createElement("header", null, /* @__PURE__ */ React.createElement("h4", null, file.previousPath ? file.previousPath + " → " + file.path : file.path), file.summary && /* @__PURE__ */ React.createElement("p", null, file.summary)), "status" in file && /* @__PURE__ */ React.createElement("p", null, labels.related, " · ", labels[file.status]), (invalid || file.unavailable) && /* @__PURE__ */ React.createElement("p", { role: "alert" }, labels.invalid), comparison && /* @__PURE__ */ React.createElement(ComparisonTargets, {
1035
1243
  comparison,
1036
1244
  labels,
1037
1245
  renderBlock,
1246
+ loadAsset: "loadAsset" in file ? file.loadAsset : void 0,
1038
1247
  review: {
1039
1248
  file: file.path,
1040
1249
  keys: reviewed,
@@ -1042,6 +1251,236 @@ function ProjectSourceReview({ title, description, verification, files, renderBl
1042
1251
  }
1043
1252
  }))));
1044
1253
  }
1254
+ // src/project-change-model.ts
1255
+ init_objectSpread2();
1256
+ var targetDataKeys = [
1257
+ "variables",
1258
+ "lists",
1259
+ "broadcasts"
1260
+ ];
1261
+ var nonSettingKeys = /* @__PURE__ */ new Set([
1262
+ "blocks",
1263
+ "costumes",
1264
+ "sounds",
1265
+ ...targetDataKeys
1266
+ ]);
1267
+ var propertyLabels = /* @__PURE__ */ new Map([
1268
+ ["variables", ["変数", "Variables"]],
1269
+ ["lists", ["リスト", "Lists"]],
1270
+ ["broadcasts", ["メッセージ", "Broadcast messages"]],
1271
+ ["costumes", ["コスチューム・背景", "Costumes and backdrops"]],
1272
+ ["sounds", ["音", "Sounds"]],
1273
+ ["blocks", ["コード", "Code"]],
1274
+ ["x", ["X 座標", "X position"]],
1275
+ ["y", ["Y 座標", "Y position"]],
1276
+ ["size", ["大きさ", "Size"]],
1277
+ ["direction", ["向き", "Direction"]],
1278
+ ["visible", ["表示状態", "Visibility"]],
1279
+ ["currentCostume", ["表示するコスチューム", "Selected costume"]],
1280
+ ["layerOrder", ["重なり順", "Layer order"]],
1281
+ ["volume", ["音量", "Volume"]],
1282
+ ["comments", ["コメント", "Comments"]],
1283
+ ["draggable", ["ドラッグ可否", "Draggable"]],
1284
+ ["rotationStyle", ["回転方法", "Rotation style"]],
1285
+ ["tempo", ["テンポ", "Tempo"]],
1286
+ ["videoTransparency", ["ビデオの透明度", "Video transparency"]],
1287
+ ["videoState", ["ビデオの状態", "Video state"]],
1288
+ ["textToSpeechLanguage", ["音声合成の言語", "Text to speech language"]],
1289
+ ["monitors", ["モニター", "Monitors"]],
1290
+ ["extensions", ["拡張機能", "Extensions"]],
1291
+ ["meta", ["作品の情報", "Project metadata"]]
1292
+ ]);
1293
+ /** The Japanese or English name of a project or target property key; unknown keys are returned as they are. */
1294
+ function projectPropertyLabel(key, locale = "en") {
1295
+ var _propertyLabels$get, _propertyLabels$get2;
1296
+ return (_propertyLabels$get = (_propertyLabels$get2 = propertyLabels.get(key)) === null || _propertyLabels$get2 === void 0 ? void 0 : _propertyLabels$get2[locale.startsWith("ja") ? 0 : 1]) !== null && _propertyLabels$get !== void 0 ? _propertyLabels$get : key;
1297
+ }
1298
+ /** The asset file name of a costume or sound entry: `md5ext`, or `assetId.dataFormat`; `''` when it has neither. */
1299
+ function assetFileName(entry) {
1300
+ if (typeof entry.md5ext === "string") return entry.md5ext;
1301
+ return typeof entry.assetId === "string" && typeof entry.dataFormat === "string" ? entry.assetId + "." + entry.dataFormat : "";
1302
+ }
1303
+ function joinPath(...parts) {
1304
+ return parts.flatMap((part) => part.split("/")).filter((part) => part !== "" && part !== ".").join("/");
1305
+ }
1306
+ function statusOf(before, after) {
1307
+ return before === void 0 ? "added" : after === void 0 ? "removed" : "updated";
1308
+ }
1309
+ function entriesOf(target, kind) {
1310
+ const list = target === null || target === void 0 ? void 0 : target[kind];
1311
+ return Array.isArray(list) ? list.filter(isRecord) : [];
1312
+ }
1313
+ function nameOf(entry) {
1314
+ return typeof entry.name === "string" ? entry.name : "";
1315
+ }
1316
+ /** Pairs the entries of two versions by name (repeated names pair in order): after's order first, then removed ones. */
1317
+ function pairEntries(before, after) {
1318
+ var _remaining$get;
1319
+ const remaining = /* @__PURE__ */ new Map();
1320
+ for (const entry of before) {
1321
+ const queue = remaining.get(nameOf(entry));
1322
+ if (queue) queue.push(entry);
1323
+ else remaining.set(nameOf(entry), [entry]);
1324
+ }
1325
+ const pairs = [];
1326
+ for (const entry of after) pairs.push({
1327
+ name: nameOf(entry),
1328
+ before: (_remaining$get = remaining.get(nameOf(entry))) === null || _remaining$get === void 0 ? void 0 : _remaining$get.shift(),
1329
+ after: entry
1330
+ });
1331
+ for (const entry of before) {
1332
+ const queue = remaining.get(nameOf(entry));
1333
+ if ((queue === null || queue === void 0 ? void 0 : queue[0]) === entry) pairs.push({
1334
+ name: nameOf(entry),
1335
+ before: queue.shift()
1336
+ });
1337
+ }
1338
+ return pairs;
1339
+ }
1340
+ function changedKeys(before, after, skip) {
1341
+ return [.../* @__PURE__ */ new Set([...Object.keys(after), ...Object.keys(before)])].filter((key) => !skip(key) && !equal(before[key], after[key]));
1342
+ }
1343
+ /** Arranges the changed files of one project folder by stage, sprite and element. Pure; reads nothing. */
1344
+ function organizeProjectChanges(input) {
1345
+ const { folder, files, assetsDirectory } = input;
1346
+ const normalize = (project) => project === null ? { targets: [] } : _objectSpread2(_objectSpread2({}, project), {}, { targets: project.targets.map((target) => {
1347
+ var _target$blocks;
1348
+ return _objectSpread2(_objectSpread2({}, target), {}, { blocks: (_target$blocks = target.blocks) !== null && _target$blocks !== void 0 ? _target$blocks : {} });
1349
+ }) });
1350
+ const before = normalize(input.before), after = normalize(input.after);
1351
+ const projectPath = joinPath(folder, "project.source.json");
1352
+ const projectFiles = files.filter((file) => file.path === projectPath);
1353
+ const explained = /* @__PURE__ */ new Set();
1354
+ let projectUsed = false;
1355
+ const useProject = () => {
1356
+ projectUsed = true;
1357
+ return [...projectFiles];
1358
+ };
1359
+ const filesAt = (paths) => {
1360
+ const wanted = new Set(paths.filter((path) => path !== void 0));
1361
+ const found = files.filter((file) => wanted.has(file.path));
1362
+ for (const file of found) explained.add(file.path);
1363
+ return found;
1364
+ };
1365
+ const review = compareProjectForReview(before, after);
1366
+ const scripts = new Map(review.targets.map((target) => {
1367
+ var _target$after;
1368
+ return [targetKey((_target$after = target.after) !== null && _target$after !== void 0 ? _target$after : target.before), target.scripts.length];
1369
+ }));
1370
+ const oldTargets = new Map(before.targets.map((target) => [targetKey(target), target]));
1371
+ const newTargets = new Map(after.targets.map((target) => [targetKey(target), target]));
1372
+ const keys = [...new Set([...after.targets, ...before.targets].map(targetKey))];
1373
+ keys.sort((a, b) => Number(b === "stage") - Number(a === "stage"));
1374
+ const targets = [];
1375
+ for (const key of keys) {
1376
+ var _scripts$get;
1377
+ const a = oldTargets.get(key), b = newTargets.get(key);
1378
+ const status = statusOf(a, b);
1379
+ const elements = [];
1380
+ const count = (_scripts$get = scripts.get(key)) !== null && _scripts$get !== void 0 ? _scripts$get : 0;
1381
+ if (count) elements.push({
1382
+ key: JSON.stringify([
1383
+ key,
1384
+ "code",
1385
+ ""
1386
+ ]),
1387
+ kind: "code",
1388
+ status,
1389
+ scripts: count,
1390
+ files: useProject()
1391
+ });
1392
+ for (const kind of ["costumes", "sounds"]) {
1393
+ const seen = /* @__PURE__ */ new Map();
1394
+ for (const pair of pairEntries(entriesOf(a, kind), entriesOf(b, kind))) {
1395
+ var _seen$get;
1396
+ const md5ext = {
1397
+ before: pair.before && assetFileName(pair.before) || void 0,
1398
+ after: pair.after && assetFileName(pair.after) || void 0
1399
+ };
1400
+ const differs = !equal(pair.before, pair.after);
1401
+ const assets = filesAt([md5ext.before && joinPath(folder, assetsDirectory.before, md5ext.before), md5ext.after && joinPath(folder, assetsDirectory.after, md5ext.after)]);
1402
+ if (!differs && !assets.length) continue;
1403
+ const repeat = (_seen$get = seen.get(pair.name)) !== null && _seen$get !== void 0 ? _seen$get : 0;
1404
+ seen.set(pair.name, repeat + 1);
1405
+ elements.push({
1406
+ key: JSON.stringify(repeat ? [
1407
+ key,
1408
+ kind,
1409
+ pair.name,
1410
+ repeat
1411
+ ] : [
1412
+ key,
1413
+ kind,
1414
+ pair.name
1415
+ ]),
1416
+ kind,
1417
+ status: statusOf(pair.before, pair.after),
1418
+ name: pair.name,
1419
+ before: pair.before,
1420
+ after: pair.after,
1421
+ md5ext,
1422
+ files: [...differs ? useProject() : [], ...assets]
1423
+ });
1424
+ }
1425
+ }
1426
+ if (a && b) {
1427
+ const oldFields = a, newFields = b;
1428
+ const data = changedKeys(oldFields, newFields, (property) => !targetDataKeys.includes(property));
1429
+ if (data.length) elements.push({
1430
+ key: JSON.stringify([
1431
+ key,
1432
+ "data",
1433
+ ""
1434
+ ]),
1435
+ kind: "data",
1436
+ status,
1437
+ properties: data,
1438
+ files: useProject()
1439
+ });
1440
+ const settings = changedKeys(oldFields, newFields, (property) => nonSettingKeys.has(property));
1441
+ if (settings.length) elements.push({
1442
+ key: JSON.stringify([
1443
+ key,
1444
+ "settings",
1445
+ ""
1446
+ ]),
1447
+ kind: "settings",
1448
+ status,
1449
+ properties: settings,
1450
+ files: useProject()
1451
+ });
1452
+ } else projectUsed = true;
1453
+ if (status !== "updated" || elements.length) {
1454
+ const { name, isStage } = b !== null && b !== void 0 ? b : a;
1455
+ targets.push({
1456
+ key,
1457
+ name,
1458
+ isStage,
1459
+ status,
1460
+ before: a,
1461
+ after: b,
1462
+ elements
1463
+ });
1464
+ }
1465
+ }
1466
+ const project = changedKeys(before, after, (key) => key === "targets").map((property) => ({
1467
+ key: JSON.stringify([
1468
+ "project",
1469
+ "settings",
1470
+ property
1471
+ ]),
1472
+ kind: "settings",
1473
+ status: statusOf(before[property], after[property]),
1474
+ properties: [property],
1475
+ files: useProject()
1476
+ }));
1477
+ if (projectUsed) explained.add(projectPath);
1478
+ return {
1479
+ targets,
1480
+ project,
1481
+ other: files.filter((file) => !explained.has(file.path))
1482
+ };
1483
+ }
1045
1484
  // src/github-pull-requests.ts
1046
1485
  init_objectSpread2();
1047
1486
  var PullRequestError = class extends Error {
@@ -1065,25 +1504,29 @@ function repoPath(repository) {
1065
1504
  var invalidResponse = () => new PullRequestError("failed");
1066
1505
  var emptyProject = "{\"targets\":[]}\n";
1067
1506
  var sourceLimit = 1048576;
1068
- async function readText(response, limit) {
1507
+ async function readBody(response, limit, chunk) {
1069
1508
  if (Number(response.headers.get("Content-Length")) > limit || !response.body) throw invalidResponse();
1070
1509
  const reader = response.body.getReader();
1071
- const decoder = new TextDecoder("utf-8", { fatal: true });
1072
- const chunks = [];
1073
1510
  let length = 0;
1074
1511
  try {
1075
1512
  for (;;) {
1076
1513
  const { done, value } = await reader.read();
1077
- if (done) return chunks.join("") + decoder.decode();
1514
+ if (done) return;
1078
1515
  length += value.byteLength;
1079
1516
  if (length > limit) throw invalidResponse();
1080
- chunks.push(decoder.decode(value, { stream: true }));
1517
+ chunk(value);
1081
1518
  }
1082
1519
  } catch (error) {
1083
1520
  reader.cancel().catch(() => {});
1084
1521
  throw error;
1085
1522
  }
1086
1523
  }
1524
+ async function readText(response, limit) {
1525
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1526
+ const chunks = [];
1527
+ await readBody(response, limit, (value) => chunks.push(decoder.decode(value, { stream: true })));
1528
+ return chunks.join("") + decoder.decode();
1529
+ }
1087
1530
  function readSummary(value) {
1088
1531
  if (!isRecord(value) || !Number.isSafeInteger(value.number) || Number(value.number) < 1 || typeof value.title !== "string") throw invalidResponse();
1089
1532
  return {
@@ -1129,6 +1572,63 @@ function contentPath(path) {
1129
1572
  if (parts.some((part) => !part || part === "." || part === "..")) throw invalidResponse();
1130
1573
  return parts.map(encodeURIComponent).join("/");
1131
1574
  }
1575
+ /**
1576
+ * Reads the assets of both versions of a project file from raw.githubusercontent.com, which does not count against the
1577
+ * API rate limit. Each version's sb3-source.json names its assets directory. Every read is capped, honors both its own
1578
+ * signal and the pull request's, and resolves `null` on any failure.
1579
+ */
1580
+ function rawAssetLoader(versions, send, review) {
1581
+ const directories = /* @__PURE__ */ new Map();
1582
+ const get = (url, signal) => send(url, {
1583
+ credentials: "omit",
1584
+ redirect: "error",
1585
+ signal
1586
+ });
1587
+ const folderUrl = ({ repository, sha, path }) => "https://raw.githubusercontent.com/" + repository.split("/").map(encodeURIComponent).join("/") + "/" + sha + "/" + contentPath(path).split("/").slice(0, -1).map((part) => part + "/").join("");
1588
+ const readDirectory = async (folder, signal) => {
1589
+ let settings = null;
1590
+ try {
1591
+ var _response$body;
1592
+ const response = await get(folder + "sb3-source.json", signal);
1593
+ if (response.ok) settings = await readText(response, folderSettingsLimit);
1594
+ else (_response$body = response.body) === null || _response$body === void 0 || _response$body.cancel().catch(() => {});
1595
+ } catch (error) {
1596
+ if (signal.aborted) throw error;
1597
+ }
1598
+ return assetsDirectoryParts(settings);
1599
+ };
1600
+ return async ({ version, md5ext, signal: own }) => {
1601
+ const location = versions[version];
1602
+ if (!location || !isAssetName(md5ext)) return null;
1603
+ try {
1604
+ const signal = AbortSignal.any([own, review]);
1605
+ if (signal.aborted) return null;
1606
+ const folder = folderUrl(location);
1607
+ let directory = directories.get(version);
1608
+ if (!directory) {
1609
+ directory = readDirectory(folder, signal);
1610
+ directories.set(version, directory);
1611
+ const pending = directory;
1612
+ pending.catch(() => {
1613
+ if (directories.get(version) === pending) directories.delete(version);
1614
+ });
1615
+ }
1616
+ const parts = await directory;
1617
+ if (!parts || signal.aborted) return null;
1618
+ const response = await get(folder + parts.map(encodeURIComponent).join("/") + "/" + md5ext, signal);
1619
+ if (!response.ok) {
1620
+ var _response$body2;
1621
+ (_response$body2 = response.body) === null || _response$body2 === void 0 || _response$body2.cancel().catch(() => {});
1622
+ return null;
1623
+ }
1624
+ const chunks = [];
1625
+ await readBody(response, assetLimit, (value) => chunks.push(value));
1626
+ return signal.aborted ? null : new Blob(chunks);
1627
+ } catch (_unused) {
1628
+ return null;
1629
+ }
1630
+ };
1631
+ }
1132
1632
  function createGitHubPullRequests(repository, send = fetch) {
1133
1633
  if (!validRepository(repository)) throw invalidResponse();
1134
1634
  const api = "https://api.github.com";
@@ -1193,13 +1693,26 @@ function createGitHubPullRequests(repository, send = fetch) {
1193
1693
  continue;
1194
1694
  }
1195
1695
  try {
1196
- var _file$previous, _pr$head$repository;
1696
+ var _file$previous, _pr$head$repository, _file$previous2, _pr$head$repository2;
1197
1697
  if (status !== "added" && !base) throw invalidResponse();
1198
1698
  const [before, after] = await Promise.all([status === "added" ? emptyProject : source(repository, (_file$previous = file.previous) !== null && _file$previous !== void 0 ? _file$previous : file.path, base, signal), status === "removed" ? emptyProject : source((_pr$head$repository = pr.head.repository) !== null && _pr$head$repository !== void 0 ? _pr$head$repository : repository, file.path, pr.head.sha, signal)]);
1699
+ const loadAsset = rawAssetLoader({
1700
+ before: status === "added" ? null : {
1701
+ repository,
1702
+ sha: base,
1703
+ path: (_file$previous2 = file.previous) !== null && _file$previous2 !== void 0 ? _file$previous2 : file.path
1704
+ },
1705
+ after: status === "removed" ? null : {
1706
+ repository: (_pr$head$repository2 = pr.head.repository) !== null && _pr$head$repository2 !== void 0 ? _pr$head$repository2 : repository,
1707
+ sha: pr.head.sha,
1708
+ path: file.path
1709
+ }
1710
+ }, send, signal);
1199
1711
  files.push({
1200
1712
  path: file.path,
1201
1713
  previousPath: file.previous,
1202
- patch: createProjectSourceDiff(before, after)
1714
+ patch: createProjectSourceDiff(before, after),
1715
+ loadAsset
1203
1716
  });
1204
1717
  } catch (error) {
1205
1718
  if (signal.aborted) throw error;
@@ -1372,9 +1885,93 @@ function placeholderDefinition(element, opcode, type, blockly, locale) {
1372
1885
  }
1373
1886
  return definition;
1374
1887
  }
1888
+ var defaultScale = .675;
1889
+ var padding = 16;
1890
+ var minHeight = 160;
1891
+ var maxHeight = 420;
1892
+ /**
1893
+ * Sizes a preview frame to its blocks and reports scrolling and zooming, so both versions can move together.
1894
+ * The frame keeps a gutter on the right for the zoom buttons, and for the minimap and thumbnail when present.
1895
+ */
1896
+ function createView(workspace, blockly, surface, frame, startScale, navigation) {
1897
+ var _bounds$x, _bounds$y, _workspace$translate, _surface$ownerDocumen;
1898
+ const bounds = workspace.getBlocksBoundingBox();
1899
+ const left = (_bounds$x = bounds.x) !== null && _bounds$x !== void 0 ? _bounds$x : 0, top = (_bounds$y = bounds.y) !== null && _bounds$y !== void 0 ? _bounds$y : 0;
1900
+ const gutter = navigation ? 150 : 56;
1901
+ const width = frame.clientWidth || 400;
1902
+ const scale = Math.max(.25, Math.min(startScale, (width - padding - gutter) / Math.max(1, bounds.width), 388 / Math.max(1, bounds.height)));
1903
+ const fit = {
1904
+ scale,
1905
+ height: Math.min(maxHeight, Math.max(minHeight, Math.ceil(bounds.height * scale + 32)))
1906
+ };
1907
+ const listeners = /* @__PURE__ */ new Set();
1908
+ const translate = (_workspace$translate = workspace.translate) === null || _workspace$translate === void 0 ? void 0 : _workspace$translate.bind(workspace);
1909
+ let quiet = false;
1910
+ if (translate) workspace.translate = (x, y) => {
1911
+ translate(x, y);
1912
+ if (!quiet) for (const listener of listeners) listener();
1913
+ };
1914
+ const setViewport = ({ scale, x, y }) => {
1915
+ if (!translate) return;
1916
+ quiet = true;
1917
+ try {
1918
+ var _workspace$getMetrics, _metrics$absoluteLeft, _metrics$absoluteTop, _workspace$scrollbar;
1919
+ workspace.scale = scale;
1920
+ workspace.scrollX = x;
1921
+ workspace.scrollY = y;
1922
+ const metrics = (_workspace$getMetrics = workspace.getMetrics) === null || _workspace$getMetrics === void 0 ? void 0 : _workspace$getMetrics.call(workspace);
1923
+ translate(x + ((_metrics$absoluteLeft = metrics === null || metrics === void 0 ? void 0 : metrics.absoluteLeft) !== null && _metrics$absoluteLeft !== void 0 ? _metrics$absoluteLeft : 0), y + ((_metrics$absoluteTop = metrics === null || metrics === void 0 ? void 0 : metrics.absoluteTop) !== null && _metrics$absoluteTop !== void 0 ? _metrics$absoluteTop : 0));
1924
+ (_workspace$scrollbar = workspace.scrollbar) === null || _workspace$scrollbar === void 0 || _workspace$scrollbar.resize();
1925
+ } finally {
1926
+ quiet = false;
1927
+ }
1928
+ };
1929
+ const layout = ({ scale, height }) => {
1930
+ frame.style.height = height + "px";
1931
+ blockly.svgResize(workspace);
1932
+ setViewport({
1933
+ scale,
1934
+ x: padding - left * scale,
1935
+ y: padding - top * scale
1936
+ });
1937
+ };
1938
+ layout(fit);
1939
+ const resize = new ((_surface$ownerDocumen = surface.ownerDocument.defaultView) !== null && _surface$ownerDocumen !== void 0 ? _surface$ownerDocumen : globalThis).ResizeObserver(() => blockly.svgResize(workspace));
1940
+ resize.observe(surface);
1941
+ const wheel = (event) => {
1942
+ if (!event.ctrlKey) event.stopPropagation();
1943
+ };
1944
+ surface.addEventListener("wheel", wheel, { capture: true });
1945
+ return {
1946
+ api: {
1947
+ fit,
1948
+ layout,
1949
+ setViewport,
1950
+ viewport: () => {
1951
+ var _workspace$scale, _workspace$scrollX, _workspace$scrollY;
1952
+ return {
1953
+ scale: (_workspace$scale = workspace.scale) !== null && _workspace$scale !== void 0 ? _workspace$scale : fit.scale,
1954
+ x: (_workspace$scrollX = workspace.scrollX) !== null && _workspace$scrollX !== void 0 ? _workspace$scrollX : 0,
1955
+ y: (_workspace$scrollY = workspace.scrollY) !== null && _workspace$scrollY !== void 0 ? _workspace$scrollY : 0
1956
+ };
1957
+ },
1958
+ onChange(listener) {
1959
+ listeners.add(listener);
1960
+ return () => listeners.delete(listener);
1961
+ }
1962
+ },
1963
+ dispose() {
1964
+ frame.style.removeProperty("height");
1965
+ listeners.clear();
1966
+ resize.disconnect();
1967
+ surface.removeEventListener("wheel", wheel, { capture: true });
1968
+ if (translate) workspace.translate = translate;
1969
+ }
1970
+ };
1971
+ }
1375
1972
  /** Use the host's Scratch VM serializer and Scratch Blocks definitions in an isolated workspace. */
1376
1973
  function createScratchBlocksDiffRenderer(options) {
1377
- return async ({ container, target, id, withContext, parts = [], marks, signal }) => {
1974
+ return async ({ container, target, id, withContext, parts = [], marks, signal, watermark }) => {
1378
1975
  var _Object$values$find;
1379
1976
  const blockly = await options.getBlockly();
1380
1977
  if (signal.aborted) return;
@@ -1400,11 +1997,16 @@ function createScratchBlocksDiffRenderer(options) {
1400
1997
  surface.className = "twgit-block-diff-surface";
1401
1998
  container.append(surface);
1402
1999
  let workspace;
2000
+ let view;
2001
+ let navigation;
2002
+ const cleanups = [];
1403
2003
  const placeholders = [];
1404
2004
  const dispose = () => {
1405
2005
  const active = blockly.getMainWorkspace();
2006
+ for (const cleanup of cleanups.splice(0)) cleanup();
1406
2007
  blockly.Events.disable();
1407
2008
  try {
2009
+ navigation === null || navigation === void 0 || navigation.dispose();
1408
2010
  workspace === null || workspace === void 0 || workspace.dispose();
1409
2011
  } finally {
1410
2012
  if (active === workspace) blockly.mainWorkspace = main;
@@ -1415,7 +2017,7 @@ function createScratchBlocksDiffRenderer(options) {
1415
2017
  };
1416
2018
  blockly.Events.disable();
1417
2019
  try {
1418
- var _main$options;
2020
+ var _main$options$zoomOpt, _main$options, _main$options2, _options$attachNaviga;
1419
2021
  for (const element of Array.from(dom.querySelectorAll("block,shadow"))) {
1420
2022
  var _element$getAttribute2, _options$locale;
1421
2023
  const opcode = (_element$getAttribute2 = element.getAttribute("type")) !== null && _element$getAttribute2 !== void 0 ? _element$getAttribute2 : "";
@@ -1429,10 +2031,20 @@ function createScratchBlocksDiffRenderer(options) {
1429
2031
  blockly.defineBlocksWithJsonArray([placeholderDefinition(element, opcode, alias, blockly, (_options$locale = options.locale) !== null && _options$locale !== void 0 ? _options$locale : container.ownerDocument.documentElement.lang)]);
1430
2032
  element.setAttribute("type", alias);
1431
2033
  }
2034
+ const startScale = (_main$options$zoomOpt = main === null || main === void 0 || (_main$options = main.options) === null || _main$options === void 0 || (_main$options = _main$options.zoomOptions) === null || _main$options === void 0 ? void 0 : _main$options.startScale) !== null && _main$options$zoomOpt !== void 0 ? _main$options$zoomOpt : defaultScale;
1432
2035
  workspace = blockly.inject(surface, {
1433
2036
  readOnly: true,
1434
2037
  sounds: false,
1435
- media: main === null || main === void 0 || (_main$options = main.options) === null || _main$options === void 0 ? void 0 : _main$options.pathToMedia
2038
+ media: main === null || main === void 0 || (_main$options2 = main.options) === null || _main$options2 === void 0 ? void 0 : _main$options2.pathToMedia,
2039
+ scrollbars: true,
2040
+ zoom: {
2041
+ controls: true,
2042
+ wheel: true,
2043
+ startScale,
2044
+ maxScale: 4,
2045
+ minScale: .25,
2046
+ scaleSpeed: 1.2
2047
+ }
1436
2048
  });
1437
2049
  workspace.markFocused = () => {};
1438
2050
  blockly.Xml.domToWorkspace(dom, workspace);
@@ -1447,10 +2059,13 @@ function createScratchBlocksDiffRenderer(options) {
1447
2059
  const changed = workspace.getBlockById(id === root ? serializedRoot : id);
1448
2060
  if (changed) markBlockLocation(changed, parts);
1449
2061
  }
1450
- const bounds = workspace.getBlocksBoundingBox();
1451
- surface.style.width = Math.max(100, Math.ceil(bounds.width) + 48) + "px";
1452
- surface.style.height = Math.max(64, Math.ceil(bounds.height) + 48) + "px";
1453
- blockly.svgResize(workspace);
2062
+ view = createView(workspace, blockly, surface, container, startScale, !!options.attachNavigation);
2063
+ cleanups.push(view.dispose);
2064
+ navigation = (_options$attachNaviga = options.attachNavigation) === null || _options$attachNaviga === void 0 ? void 0 : _options$attachNaviga.call(options, workspace, blockly, watermark);
2065
+ if (navigation && watermark) {
2066
+ watermark.dataset.navigation = "";
2067
+ cleanups.push(() => delete watermark.dataset.navigation);
2068
+ }
1454
2069
  } catch (error) {
1455
2070
  dispose();
1456
2071
  throw error;
@@ -1458,7 +2073,10 @@ function createScratchBlocksDiffRenderer(options) {
1458
2073
  blockly.mainWorkspace = main;
1459
2074
  blockly.Events.enable();
1460
2075
  }
1461
- return dispose;
2076
+ return {
2077
+ dispose,
2078
+ view: view.api
2079
+ };
1462
2080
  };
1463
2081
  }
1464
- export { gitHubRepositoryFromOrigin as a, compareProjectBlocks as c, BlockPreview as d, createGitHubPullRequests as i, createProjectSourceDiff as l, createScratchBlocksDiffLocator as n, ProjectSourceComparison as o, PullRequestError as r, ProjectSourceReview as s, createScratchBlocksDiffRenderer as t, readProjectSourceDiff as u };
2082
+ export { compareProjectBlocks as _, gitHubRepositoryFromOrigin as a, readProjectSourceDiff as b, projectPropertyLabel as c, BlockPreview as d, createPreviewSync as f, useCostumeImage as g, useAssetImage as h, createGitHubPullRequests as i, ProjectSourceComparison as l, currentCostume as m, createScratchBlocksDiffLocator as n, assetFileName as o, usePreviewSync as p, PullRequestError as r, organizeProjectChanges as s, createScratchBlocksDiffRenderer as t, ProjectSourceReview as u, createProjectSourceDiff as v, parseProjectSource as y };