@kubohiroya/turbowarp-plus-editor-git 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16795,6 +16795,7 @@ async function runRemoteOperation(root, operation, getSession, guard, signal) {
16795
16795
  }
16796
16796
  // src/repository.ts
16797
16797
  init_objectSpread2();
16798
+ var projectSourceLimit = 16777216;
16798
16799
  var emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
16799
16800
  var textLimit = 1048576;
16800
16801
  var inScope = (path, scope) => !scope || path === scope || path.startsWith(scope + "/");
@@ -17170,13 +17171,31 @@ async function openRepository(root) {
17170
17171
  }));
17171
17172
  return blob.byteLength <= limit ? blob : null;
17172
17173
  };
17173
- const readAsset = async (snapshot, change, version, md5ext, signal) => {
17174
+ const comparisonSource = async (snapshot, comparison, version) => {
17175
+ if (comparison.kind === "worktree") return version === "after" ? { kind: "worktree" } : snapshot.head ? {
17176
+ kind: "tree",
17177
+ ref: snapshot.head
17178
+ } : null;
17179
+ if (version === "after") return {
17180
+ kind: "tree",
17181
+ ref: comparison.commit
17182
+ };
17183
+ const parent = (await readCommit(_objectSpread2(_objectSpread2({}, options), {}, {
17184
+ cache,
17185
+ oid: comparison.commit
17186
+ }))).commit.parent[0];
17187
+ return parent ? {
17188
+ kind: "tree",
17189
+ ref: parent
17190
+ } : null;
17191
+ };
17192
+ const readAsset = (snapshot, change, version, md5ext, signal) => readFolderAsset(snapshot, pathParts(change.path).slice(0, -1), () => sourceOf(snapshot, change, version), md5ext, signal);
17193
+ const readFolderAsset = async (snapshot, folder, resolve, md5ext, signal) => {
17174
17194
  if (signal.aborted) return null;
17175
17195
  fs.clearReads();
17176
17196
  await assertSnapshot(snapshot);
17177
- const source = await sourceOf(snapshot, change, version);
17197
+ const source = await resolve();
17178
17198
  if (!source) return null;
17179
- const folder = pathParts(change.path).slice(0, -1);
17180
17199
  const settings = await readVersion(source, [...folder, "sb3-source.json"].join("/"), folderSettingsLimit);
17181
17200
  const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
17182
17201
  if (!directory || signal.aborted) return null;
@@ -17188,6 +17207,43 @@ async function openRepository(root) {
17188
17207
  await fs.checkReads();
17189
17208
  return bytes && !signal.aborted ? new Blob([bytes]) : null;
17190
17209
  };
17210
+ /** Reads the project file and the assets directory of both versions of a project folder. */
17211
+ const readProject = async (snapshot, folder, comparison) => {
17212
+ fs.clearReads();
17213
+ await assertSnapshot(snapshot);
17214
+ const parts = pathParts(folder);
17215
+ const read = async (version) => {
17216
+ const source = await comparisonSource(snapshot, comparison, version);
17217
+ if (!source) return {
17218
+ source: null,
17219
+ assetsDirectory: "assets"
17220
+ };
17221
+ const bytes = await readVersion(source, [...parts, "project.source.json"].join("/"), projectSourceLimit);
17222
+ const settings = await readVersion(source, [...parts, "sb3-source.json"].join("/"), folderSettingsLimit);
17223
+ const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
17224
+ let text = null;
17225
+ if (bytes) try {
17226
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
17227
+ } catch (_unused4) {
17228
+ throw new GitPanelError("unsupported");
17229
+ }
17230
+ return {
17231
+ source: text,
17232
+ assetsDirectory: directory ? directory.join("/") : "assets"
17233
+ };
17234
+ };
17235
+ const before = await read("before"), after = await read("after");
17236
+ await assertSnapshot(snapshot);
17237
+ await fs.checkReads();
17238
+ return {
17239
+ before: before.source,
17240
+ after: after.source,
17241
+ assetsDirectory: {
17242
+ before: before.assetsDirectory,
17243
+ after: after.assetsDirectory
17244
+ }
17245
+ };
17246
+ };
17191
17247
  const directoryAt = async (path) => {
17192
17248
  let directory = root;
17193
17249
  for (const part of pathParts(path)) directory = await directory.getDirectoryHandle(part);
@@ -17274,7 +17330,14 @@ async function openRepository(root) {
17274
17330
  * commit changes the first parent with the commit. Each version's sb3-source.json names its assets directory.
17275
17331
  * Reads never write, and resolve `null` for anything missing, invalid, larger than 10 MiB or changed since the snapshot.
17276
17332
  */
17277
- assetLoader: (snapshot, change) => ({ version, md5ext, signal }) => !isAssetName(md5ext) || signal.aborted ? Promise.resolve(null) : serial(() => readAsset(snapshot, change, version, md5ext, signal)).catch(() => null)
17333
+ assetLoader: (snapshot, change) => ({ version, md5ext, signal }) => !isAssetName(md5ext) || signal.aborted ? Promise.resolve(null) : serial(() => readAsset(snapshot, change, version, md5ext, signal)).catch(() => null),
17334
+ /**
17335
+ * Reads the project file of a folder in both versions of a comparison, with each version's assets directory.
17336
+ * `null` stands for a version without the file. Throws `changed` when the repository changed since the snapshot.
17337
+ */
17338
+ readProject: (snapshot, folder, comparison) => serial(() => readProject(snapshot, folder, comparison)),
17339
+ /** Reads the assets of a folder in both versions of a comparison, like `assetLoader`. */
17340
+ projectAssetLoader: (snapshot, folder, comparison) => ({ version, md5ext, signal }) => !isAssetName(md5ext) || signal.aborted ? Promise.resolve(null) : serial(() => readFolderAsset(snapshot, pathParts(folder), () => comparisonSource(snapshot, comparison, version), md5ext, signal)).catch(() => null)
17278
17341
  };
17279
17342
  }
17280
17343
  export { GitPanelError as n, createReadOnlyFs as r, openRepository as t };
@@ -19,6 +19,16 @@ export interface Commit {
19
19
  author: string;
20
20
  timestamp: number;
21
21
  }
22
+ /**
23
+ * Two versions of a project folder as a person sees them: the last commit and the working tree, so changes split
24
+ * across the index and the working tree read as one; or one commit and its first parent.
25
+ */
26
+ export type ProjectComparison = {
27
+ kind: 'worktree';
28
+ } | {
29
+ kind: 'commit';
30
+ commit: string;
31
+ };
22
32
  type Metadata = Map<string, Uint8Array | null>;
23
33
  export interface Snapshot {
24
34
  branch: string | undefined;
@@ -67,5 +77,19 @@ export declare function openRepository(root: FileSystemDirectoryHandle): Promise
67
77
  * Reads never write, and resolve `null` for anything missing, invalid, larger than 10 MiB or changed since the snapshot.
68
78
  */
69
79
  assetLoader: (snapshot: Snapshot, change: FileChange) => ProjectAssetLoader;
80
+ /**
81
+ * Reads the project file of a folder in both versions of a comparison, with each version's assets directory.
82
+ * `null` stands for a version without the file. Throws `changed` when the repository changed since the snapshot.
83
+ */
84
+ readProject: (snapshot: Snapshot, folder: string, comparison: ProjectComparison) => Promise<{
85
+ before: string | null;
86
+ after: string | null;
87
+ assetsDirectory: {
88
+ before: string;
89
+ after: string;
90
+ };
91
+ }>;
92
+ /** Reads the assets of a folder in both versions of a comparison, like `assetLoader`. */
93
+ projectAssetLoader: (snapshot: Snapshot, folder: string, comparison: ProjectComparison) => ProjectAssetLoader;
70
94
  }>;
71
95
  export type Repository = Awaited<ReturnType<typeof openRepository>>;
@@ -1 +1 @@
1
- export { n as GitPanelError, r as createReadOnlyFs, t as openRepository } from "./repository-BQkADJmK.js";
1
+ export { n as GitPanelError, r as createReadOnlyFs, t as openRepository } from "./repository-VBp-UNWc.js";
@@ -694,18 +694,23 @@ function readAsset(loadAsset, version, md5ext) {
694
694
  }
695
695
  return read;
696
696
  }
697
- /** Loads the current costume of one version of a target as an object URL for an image, or `null` while it is unavailable. */
698
- function useCostumeImage(target, version, loadAsset) {
699
- const costume = target && loadAsset ? currentCostume(target) : null;
700
- const key = costume ? version + ":" + costume.md5ext : "";
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 : "";
701
706
  const [image, setImage] = useState();
702
707
  useEffect(() => {
703
- if (!costume || !loadAsset) return;
708
+ if (!key || !loadAsset) return;
704
709
  let active = true;
705
710
  let url;
706
- readAsset(loadAsset, version, costume.md5ext).then((blob) => {
711
+ readAsset(loadAsset, version, md5ext).then((blob) => {
707
712
  if (!blob || !active) return;
708
- url = URL.createObjectURL(new Blob([blob], { type: costume.type }));
713
+ url = URL.createObjectURL(new Blob([blob], { type }));
709
714
  setImage({
710
715
  key,
711
716
  url
@@ -718,6 +723,11 @@ function useCostumeImage(target, version, loadAsset) {
718
723
  }, [key, loadAsset]);
719
724
  return (image === null || image === void 0 ? void 0 : image.key) === key ? image.url : null;
720
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
+ }
721
731
  // src/block-diff-preview.tsx
722
732
  init_objectSpread2();
723
733
  function createPreviewSync() {
@@ -968,6 +978,7 @@ function compareProjectForReview(before, after) {
968
978
  };
969
979
  }
970
980
  // src/project-source-review.tsx
981
+ init_objectSpread2();
971
982
  var ja = {
972
983
  review: "変更レビュー",
973
984
  fullDescription: "PR 本文を表示",
@@ -1149,15 +1160,26 @@ function ComparisonTargets({ comparison, labels, renderBlock, loadAsset, review
1149
1160
  }
1150
1161
  var countScripts = (comparison) => comparison.targets.reduce((count, target) => count + target.scripts.length, 0);
1151
1162
  /** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
1152
- function ProjectSourceComparison({ before, after, renderBlock, locale = "en", fallback, loadAsset }) {
1163
+ function ProjectSourceComparison({ before, after, renderBlock, locale = "en", fallback, loadAsset, target }) {
1153
1164
  const labels = locale.startsWith("ja") ? ja : en;
1165
+ const only = target && JSON.stringify([target.isStage, target.isStage || target.name]);
1154
1166
  const comparison = useMemo(() => {
1167
+ let result;
1155
1168
  try {
1156
- return compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
1169
+ result = compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
1157
1170
  } catch (_unused) {
1158
1171
  return null;
1159
1172
  }
1160
- }, [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
+ ]);
1161
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", {
1162
1184
  role: "alert",
1163
1185
  className: "twgit-review-unreadable"
@@ -1165,7 +1187,7 @@ function ProjectSourceComparison({ before, after, renderBlock, locale = "en", fa
1165
1187
  return /* @__PURE__ */ React.createElement("section", {
1166
1188
  className: "twgit-block-diff twgit-review twgit-review-comparison",
1167
1189
  "aria-label": labels.comparison
1168
- }, /* @__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, {
1169
1191
  comparison,
1170
1192
  labels,
1171
1193
  renderBlock,
@@ -1229,6 +1251,236 @@ function ProjectSourceReview({ title, description, verification, files, renderBl
1229
1251
  }
1230
1252
  }))));
1231
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
+ }
1232
1484
  // src/github-pull-requests.ts
1233
1485
  init_objectSpread2();
1234
1486
  var PullRequestError = class extends Error {
@@ -1827,4 +2079,4 @@ function createScratchBlocksDiffRenderer(options) {
1827
2079
  };
1828
2080
  };
1829
2081
  }
1830
- export { gitHubRepositoryFromOrigin as a, BlockPreview as c, currentCostume as d, compareProjectBlocks as f, createGitHubPullRequests as i, createPreviewSync as l, readProjectSourceDiff as m, createScratchBlocksDiffLocator as n, ProjectSourceComparison as o, createProjectSourceDiff as p, PullRequestError as r, ProjectSourceReview as s, createScratchBlocksDiffRenderer as t, usePreviewSync 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 };
package/dist/style.css CHANGED
@@ -135,4 +135,21 @@
135
135
  .twgit-view-switch { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 10px; }
136
136
  .twgit-review-unreadable { margin: 6px 0; padding: 8px; background: color-mix(in srgb, #d9822b 16%, Canvas); }
137
137
  .twgit-review-comparison .twgit-review-overview dl { margin: 4px 0 8px; }
138
+ .twgit-element-list > section { margin: 6px 0 10px; }
139
+ .twgit-element-target { display: flex; align-items: center; gap: 6px; margin: 10px 0 4px; font-size: 14px; }
140
+ .twgit-element-target img, .twgit-element img, .twgit-element-thumbnail { width: 28px; height: 28px; object-fit: contain; flex: none; }
141
+ .twgit-element-thumbnail { display: inline-block; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
142
+ .twgit-panel .twgit-element { display: flex; align-items: center; gap: 6px; padding-inline-start: 10px; }
143
+ .twgit-element-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
144
+ .twgit-file-list { margin-top: 10px; }
145
+ .twgit-element-pictures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
146
+ .twgit-element-picture { margin: 0; padding: 10px; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 6px; }
147
+ .twgit-element-picture img { display: block; max-width: 100%; max-height: 260px; margin: 8px auto 0; object-fit: contain;
148
+ background: repeating-conic-gradient(color-mix(in srgb, CanvasText 6%, Canvas) 0 25%, Canvas 0 50%) 0 0 / 16px 16px; }
149
+ .twgit-element-values { border-collapse: collapse; width: 100%; font-size: 13px; }
150
+ .twgit-element-values th, .twgit-element-values td { text-align: start; vertical-align: top; padding: 6px 8px;
151
+ border-bottom: 1px solid color-mix(in srgb, CanvasText 15%, Canvas); overflow-wrap: anywhere; }
152
+ .twgit-element-values td { font-family: ui-monospace, monospace; }
153
+ .twgit-element-files { margin-top: 12px; }
154
+ .twgit-panel .twgit-element span.twgit-element-name { width: auto; font-family: inherit; }
138
155
  /*$vite$:1*/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-git",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
package/src/index.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import React, {useEffect, useMemo, useRef, useState} from 'react';
2
2
  import {render, unmountComponentAtNode} from 'react-dom';
3
3
  import {FileDiffView} from './file-diff-view.js';
4
+ import {findElement, ProjectChangeListView, ProjectElementView, projectChangeLabels, useProjectChanges} from './project-change-list.js';
5
+ import type {ChangedFile} from './project-change-model.js';
4
6
  import {openRepository} from './repository.js';
5
7
  import type {Repository, Snapshot, FileChange, FileDiff, ChangeGroup} from './repository.js';
6
8
  import {labelsFor} from './messages.js';
@@ -214,6 +216,20 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
214
216
  });
215
217
  const selectFile = (selected: FileChange) => run(async () => ({selected,
216
218
  diff: await state.repository!.readDiff(state.snapshot!, selected)}));
219
+ // A selected stage or sprite element replaces the file diff; picking a file goes back to it.
220
+ const [element, setElement] = useState<string | null>(null);
221
+ const [fileListOpen, setFileListOpen] = useState(false);
222
+ const projectChanges = useProjectChanges(state.repository, state.snapshot, state.project, state.commit, state.changes);
223
+ const openFile = (file: ChangedFile | FileChange) => {
224
+ const change = state.changes.find(item => item.path === file.path && item.group === file.group);
225
+ if (!change) return;
226
+ setElement(null);
227
+ void selectFile(change);
228
+ };
229
+ const openElement = (key: string) => {
230
+ setElement(key);
231
+ setState(previous => ({...previous, selected: null, diff: null}));
232
+ };
217
233
  const selectCommit = (commit: string) => run(async () => ({commit,
218
234
  changes: await state.repository!.historyChanges(state.snapshot!, commit), selected: null, diff: null}));
219
235
  const remoteOperation = async (operation: RemoteOperation) => {
@@ -254,6 +270,8 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
254
270
  state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
255
271
  if (!visible) return null;
256
272
  const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
273
+ const projectList = typeof projectChanges === 'object' ? projectChanges : null;
274
+ const selection = projectList && !state.selected ? findElement(projectList.list, element) : null;
257
275
  return <div className="twgit-backdrop">
258
276
  <div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
259
277
  onKeyDown={focusTrap}>
@@ -297,15 +315,22 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
297
315
  onClick={() => void browse([state.browser!.path, name].filter(Boolean).join('/'))}>{name}/</button></li>)}</ul>
298
316
  </details>}
299
317
  <h3><button disabled={state.busy} onClick={() => void controller.refresh()}>{labels.worktree}</button></h3>
300
- {groups.map(group => <section key={group}>
301
- <h3>{labels[group]} · {state.changes.filter(change => change.group === group).length}</h3>
302
- <ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}>
303
- <button className="twgit-file" data-git-group={group} disabled={state.busy}
304
- aria-pressed={state.selected === change} onClick={() => void selectFile(change)}>
305
- <span>{!change.after ? 'D' : !change.before ? 'A' : 'M'}</span> {change.path}
306
- </button>
307
- </li>)}</ul>
308
- </section>)}
318
+ {projectChanges === 'loading' && <p role="status">{projectChangeLabels(locale).loading}</p>}
319
+ {projectChanges === 'unreadable' && <p>{projectChangeLabels(locale).unreadable}</p>}
320
+ {(() => {
321
+ const fileList = groups.map(group => <section key={group}>
322
+ <h3>{labels[group]} · {state.changes.filter(change => change.group === group).length}</h3>
323
+ <ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}>
324
+ <button className="twgit-file" data-git-group={group} disabled={state.busy}
325
+ aria-pressed={state.selected === change} onClick={() => openFile(change)}>
326
+ <span>{!change.after ? 'D' : !change.before ? 'A' : 'M'}</span> {change.path}
327
+ </button>
328
+ </li>)}</ul>
329
+ </section>);
330
+ return projectList ? <ProjectChangeListView changes={projectList} selected={selection ? element : null}
331
+ locale={locale} busy={state.busy} onSelect={openElement} onSelectFile={openFile} fileList={fileList}
332
+ fileListOpen={fileListOpen} setFileListOpen={setFileListOpen}/> : fileList;
333
+ })()}
309
334
  {!state.changes.length && <p>{labels.clean}</p>}
310
335
  <details><summary>{labels.branches}</summary><ul>{state.snapshot?.branches.map(branch => <li key={branch}>{branch}</li>)}</ul></details>
311
336
  <h3>{labels.history}</h3>
@@ -316,8 +341,10 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
316
341
  </li>)}</ol>
317
342
  </aside>
318
343
  <main className="twgit-diff">
319
- {state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
320
- renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
344
+ {selection && projectList ? <ProjectElementView changes={projectList} selection={selection} locale={locale}
345
+ renderBlock={host.renderReviewBlock} onSelectFile={openFile}/> :
346
+ state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
347
+ renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
321
348
  </main>
322
349
  </div>}
323
350
  <footer>{labels.permissions}</footer>
@@ -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
+ }