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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -116,14 +116,24 @@
116
116
  .twgit-panel button:focus-visible, .twgit-panel summary:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
117
117
  .twgit-panel button[aria-pressed="true"] { color: HighlightText; background: Highlight; }
118
118
  .twgit-context { display: flex; gap: 8px 20px; flex-wrap: wrap; padding: 8px 16px; }
119
- .twgit-connection { margin: 0 16px 8px; max-height: 45%; overflow: auto; }
119
+ .twgit-context { align-items: center; }
120
+ .twgit-connection, .twgit-git-details { position: relative; }
121
+ .twgit-connection > summary, .twgit-git-details > summary { cursor: pointer; }
122
+ /* Settings and Git details open over the list, so the list and the diff stay where they are. */
123
+ .twgit-connection-body { position: absolute; z-index: 5; top: calc(100% + 4px); left: 0; width: min(440px, 80vw); max-height: 60vh;
124
+ overflow: auto; padding: 10px 12px; background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 6px;
125
+ box-shadow: 0 8px 24px #0004; }
126
+ .twgit-connection-body > p { margin: 4px 0; overflow-wrap: anywhere; }
127
+ .twgit-sync { display: inline-flex; flex-wrap: wrap; gap: 4px; }
128
+ .twgit-change-kind { display: inline-block; min-width: 2.6em; font-size: 11px; opacity: .8; }
129
+ .twgit-file-details { font-size: 12px; margin-bottom: 6px; }
130
+ .twgit-file-details > p { margin: 2px 0; }
120
131
  .twgit-content { display: grid; grid-template-columns: minmax(230px, 30%) minmax(0, 1fr); flex: 1; min-height: 0; }
121
132
  .twgit-panel aside { padding: 0 14px 14px; overflow: auto; border-right: 1px solid GrayText; }
122
133
  .twgit-panel aside ul { list-style: none; padding: 0; margin: 4px 0; }
123
134
  .twgit-panel aside li { overflow-wrap: anywhere; }
124
135
  .twgit-panel summary { cursor: pointer; }
125
136
  .twgit-panel .twgit-file { width: 100%; border: 0; margin: 1px 0; }
126
- .twgit-file span { display: inline-block; width: 1.4em; font-family: monospace; }
127
137
  .twgit-diff { padding: 0 16px 16px; min-width: 0; overflow: auto; }
128
138
  .twgit-diff .diff { font-size: 12px; }
129
139
  .twgit-history { list-style: none; margin: 0; padding: 0; }
@@ -135,4 +145,22 @@
135
145
  .twgit-view-switch { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 10px; }
136
146
  .twgit-review-unreadable { margin: 6px 0; padding: 8px; background: color-mix(in srgb, #d9822b 16%, Canvas); }
137
147
  .twgit-review-comparison .twgit-review-overview dl { margin: 4px 0 8px; }
148
+ .twgit-element-list > section { margin: 6px 0 10px; }
149
+ .twgit-element-target { display: flex; align-items: center; gap: 6px; margin: 10px 0 4px; font-size: 14px; }
150
+ .twgit-element-target img, .twgit-element img, .twgit-element-thumbnail { width: 28px; height: 28px; object-fit: contain; flex: none; }
151
+ .twgit-element-thumbnail { display: inline-block; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
152
+ .twgit-panel .twgit-element { display: flex; align-items: center; gap: 6px; padding-inline-start: 10px; }
153
+ .twgit-element-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
154
+ .twgit-file-list { margin-top: 10px; }
155
+ .twgit-element-pictures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
156
+ .twgit-element-picture { margin: 0; padding: 10px; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 6px; }
157
+ .twgit-element-picture img { display: block; max-width: 100%; max-height: 260px; margin: 8px auto 0; object-fit: contain;
158
+ background: repeating-conic-gradient(color-mix(in srgb, CanvasText 6%, Canvas) 0 25%, Canvas 0 50%) 0 0 / 16px 16px; }
159
+ .twgit-element-values { border-collapse: collapse; width: 100%; font-size: 13px; }
160
+ .twgit-element-values th, .twgit-element-values td { text-align: start; vertical-align: top; padding: 6px 8px;
161
+ border-bottom: 1px solid color-mix(in srgb, CanvasText 15%, Canvas); overflow-wrap: anywhere; }
162
+ .twgit-element-values td { font-family: ui-monospace, monospace; }
163
+ .twgit-element-files { margin-top: 12px; }
164
+ .twgit-panel .twgit-element span.twgit-element-name { width: auto; font-family: inherit; }
165
+ .twgit-git-details > .twgit-connection-body { left: auto; right: 0; }
138
166
  /*$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.10.0",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -28,7 +28,7 @@ const en = {
28
28
  unavailable: 'No GitHub connection is configured.', changed: 'Switching disconnects the current session.',
29
29
  error: 'GitHub connection failed. Retry using the selected connection.',
30
30
  invalid: 'The selected repository needs a GitHub HTTPS origin.',
31
- allowPush: 'Authorize push', fetch: 'Fetch', pull: 'Pull (fast-forward only)', push: 'Push'
31
+ allowPush: 'Allow sending to GitHub (push)', fetch: 'Check GitHub (fetch)', pull: 'Get from GitHub (pull)', push: 'Send to GitHub (push)'
32
32
  };
33
33
  const ja: typeof en = {
34
34
  title: 'GitHub 接続', mode: '接続方式', select: '接続方式を選択',
@@ -39,7 +39,7 @@ const ja: typeof en = {
39
39
  unavailable: 'GitHub 接続先が設定されていません。', changed: '方式を切り替えると現在のセッションを切断します。',
40
40
  error: 'GitHub 接続に失敗しました。選択中の方式で再試行してください。',
41
41
  invalid: '選択したリポジトリには GitHub の HTTPS リモート origin が必要です。',
42
- allowPush: 'push を許可する', fetch: 'Fetch', pull: 'Pull(fast-forward のみ)', push: 'Push'
42
+ allowPush: 'GitHub に送ることを許可する(push)', fetch: 'GitHub と確かめる(fetch)', pull: 'GitHub から取り込む(pull)', push: 'GitHub に送る(push)'
43
43
  };
44
44
 
45
45
  export function ConnectionSettings({settings, locale, readOrigin, runOperation}: {
@@ -69,8 +69,15 @@ export function ConnectionSettings({settings, locale, readOrigin, runOperation}:
69
69
  if (!remote) throw new Error('INVALID_REMOTE');
70
70
  await settings.connect(remote, allowPush);
71
71
  });
72
- return <details className="twgit-connection">
72
+ const connected = !!mode && state.status === 'connected';
73
+ // Sync sits beside the GitHub status; the settings open over the list instead of pushing it down.
74
+ return <>
75
+ {runOperation && connected && <span className="twgit-sync">{(['fetch', 'pull', 'push'] as const).map(operation =>
76
+ <button key={operation} disabled={busy || !readOrigin || (operation === 'push' && !write)}
77
+ onClick={() => void run(() => runOperation(operation))}>{labels[operation]}</button>)}</span>}
78
+ <details className="twgit-connection">
73
79
  <summary>{labels.title}</summary>
80
+ <div className="twgit-connection-body">
74
81
  {!settings.modes.length ? <p>{labels.unavailable}</p> : <>
75
82
  <label>{labels.mode} <select value={mode ?? ''} disabled={busy}
76
83
  onChange={event => {const choice = event.currentTarget.value as Mode; void run(() => settings.select(choice));}}>
@@ -93,11 +100,10 @@ export function ConnectionSettings({settings, locale, readOrigin, runOperation}:
93
100
  {state.status !== 'disconnected' && <button disabled={busy}
94
101
  onClick={() => void run(settings.refresh)}>{labels.check}</button>}
95
102
  <button disabled={busy} onClick={() => void run(settings.disconnect)}>{labels.disconnect}</button>
96
- {runOperation && state.status === 'connected' && (['fetch', 'pull', 'push'] as const).map(operation =>
97
- <button key={operation} disabled={busy || !readOrigin || (operation === 'push' && !write)}
98
- onClick={() => void run(() => runOperation(operation))}>{labels[operation]}</button>)}
99
103
  </>}
100
- {error && <p className="twgit-error" role="alert">{labels[error]}</p>}
101
104
  </>}
102
- </details>;
105
+ </div>
106
+ </details>
107
+ {error && <p className="twgit-error" role="alert">{labels[error]}</p>}
108
+ </>;
103
109
  }
@@ -23,8 +23,10 @@ export function FileDiffView({change, diff, labels, locale, renderBlock, loadAss
23
23
  const names = locale.startsWith('ja') ? ja : en;
24
24
  const parsed = diff?.patch ? parseDiff(diff.patch)[0] : null;
25
25
  const text = <>
26
- <p>{labels.mode} · {change.oldMode?.toString(8) || '—'} → {change.newMode?.toString(8) || '—'}</p>
27
- {diff && <p>{diff.beforeSize} → {diff.afterSize} {labels.bytes}</p>}
26
+ <details className="twgit-file-details"><summary>{labels.details}</summary>
27
+ <p>{labels.mode} · {change.oldMode?.toString(8) || '—'} → {change.newMode?.toString(8) || '—'}</p>
28
+ {diff && <p>{diff.beforeSize} → {diff.afterSize} {labels.bytes}</p>}
29
+ </details>
28
30
  {diff?.binary && <p>{labels.binary}</p>}
29
31
  {diff?.tooLarge && <p>{labels.large}</p>}
30
32
  {parsed && <Diff viewType="unified" diffType={parsed.type} hunks={parsed.hunks}>
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}>
@@ -269,17 +287,26 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
269
287
  <div className="twgit-context">
270
288
  <span>{labels.repository} · {state.repository?.root.name || '—'}</span>
271
289
  <span>{labels.project} · {state.project === null ? labels.none : state.project || labels.root}</span>
272
- <strong>{state.snapshot?.branch || (state.snapshot?.head ? labels.detached : labels.unborn)}</strong>
273
- <code>{state.snapshot?.head?.slice(0, 12)}</code>
274
290
  <span data-git-dirty="">{dirty ? labels.dirty : labels.saved}</span>
291
+ {state.snapshot && <span data-git-upstream={state.snapshot.upstream.state} title={labels.checkedAt}>{upstreamText(state.snapshot, labels)}</span>}
292
+ {!review && host.connectionSettings && <ConnectionSettings settings={host.connectionSettings} locale={locale}
293
+ runOperation={host.connectionSettings.getSession ? remoteOperation : undefined}
294
+ readOrigin={state.repository && !state.busy ? async () => {
295
+ const ticket = epoch.current;
296
+ const remote = await state.repository!.readOrigin();
297
+ return epoch.current === ticket ? remote : undefined;
298
+ } : undefined}/>}
299
+ {state.snapshot && <details className="twgit-git-details">
300
+ <summary>{labels.details}</summary>
301
+ <div className="twgit-connection-body">
302
+ <p>{labels.branch} · <strong>{state.snapshot.branch || (state.snapshot.head ? labels.detached : labels.unborn)}</strong></p>
303
+ <p>{labels.head} · <code>{state.snapshot.head?.slice(0, 12) ?? '—'}</code></p>
304
+ <p>{labels.branches} · {state.snapshot.branches.join(', ') || '—'}</p>
305
+ <p>{labels.groups} · {labels.gitStaged} = {labels.staged} / {labels.gitUnstaged} = {labels.unstaged} / {labels.gitUntracked} = {labels.untracked}</p>
306
+ <p>{labels.permissions}</p>
307
+ </div>
308
+ </details>}
275
309
  </div>
276
- {!review && host.connectionSettings && <ConnectionSettings settings={host.connectionSettings} locale={locale}
277
- runOperation={host.connectionSettings.getSession ? remoteOperation : undefined}
278
- readOrigin={state.repository && !state.busy ? async () => {
279
- const ticket = epoch.current;
280
- const remote = await state.repository!.readOrigin();
281
- return epoch.current === ticket ? remote : undefined;
282
- } : undefined}/>}
283
310
  {state.error && <p className="twgit-error" role="alert">{state.error}</p>}
284
311
  {state.notice && <p className="twgit-context" role="status">{state.notice}</p>}
285
312
  {state.busy && <p role="status">{labels.loading}</p>}
@@ -297,34 +324,52 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
297
324
  onClick={() => void browse([state.browser!.path, name].filter(Boolean).join('/'))}>{name}/</button></li>)}</ul>
298
325
  </details>}
299
326
  <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>)}
327
+ {projectChanges === 'loading' && <p role="status">{projectChangeLabels(locale).loading}</p>}
328
+ {projectChanges === 'unreadable' && <p>{projectChangeLabels(locale).unreadable}</p>}
329
+ {(() => {
330
+ const fileList = groups.map(group => <section key={group}>
331
+ <h3>{labels[group]} · {state.changes.filter(change => change.group === group).length}</h3>
332
+ <ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}>
333
+ <button className="twgit-file" data-git-group={group} disabled={state.busy}
334
+ aria-pressed={state.selected === change} onClick={() => openFile(change)}>
335
+ <small className="twgit-change-kind">{!change.after ? labels.deleted : !change.before ? labels.added : labels.modified}</small> {change.path}
336
+ </button>
337
+ </li>)}</ul>
338
+ </section>);
339
+ return projectList ? <ProjectChangeListView changes={projectList} selected={selection ? element : null}
340
+ locale={locale} busy={state.busy} onSelect={openElement} onSelectFile={openFile} fileList={fileList}
341
+ fileListOpen={fileListOpen} setFileListOpen={setFileListOpen}/> : fileList;
342
+ })()}
309
343
  {!state.changes.length && <p>{labels.clean}</p>}
310
- <details><summary>{labels.branches}</summary><ul>{state.snapshot?.branches.map(branch => <li key={branch}>{branch}</li>)}</ul></details>
311
344
  <h3>{labels.history}</h3>
312
345
  <ol className="twgit-history">{state.snapshot?.commits.map(commit => <li key={commit.oid}>
313
346
  <button disabled={state.busy} aria-pressed={state.commit === commit.oid} title={commit.oid}
314
347
  onClick={() => void selectCommit(commit.oid)}>{commit.message.split('\n')[0]}</button>
315
- <small><code>{commit.oid.slice(0, 8)}</code> · {commit.author} · {new Date(commit.timestamp * 1000).toLocaleString(locale)}</small>
348
+ <small>{commit.author} · {new Date(commit.timestamp * 1000).toLocaleString(locale)}</small>
316
349
  </li>)}</ol>
317
350
  </aside>
318
351
  <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>}
352
+ {selection && projectList ? <ProjectElementView changes={projectList} selection={selection} locale={locale}
353
+ renderBlock={host.renderReviewBlock} onSelectFile={openFile}/> :
354
+ state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
355
+ renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
321
356
  </main>
322
357
  </div>}
323
- <footer>{labels.permissions}</footer>
324
358
  </div>
325
359
  </div>;
326
360
  }
327
361
 
362
+ /** The GitHub relation in plain words, with the number of records where it helps. */
363
+ function upstreamText({upstream}: Snapshot, labels: ReturnType<typeof labelsFor>) {
364
+ switch (upstream.state) {
365
+ case 'same': return labels.upstreamSame;
366
+ case 'ahead': return `${labels.upstreamAhead} · ${upstream.ahead} ${labels.records}`;
367
+ case 'behind': return `${labels.upstreamBehind} · ${upstream.behind} ${labels.records}`;
368
+ case 'diverged': return `${labels.upstreamDiverged} · +${upstream.ahead} / −${upstream.behind}`;
369
+ default: return labels.upstreamUnknown;
370
+ }
371
+ }
372
+
328
373
  export function mountGitPanel({vm, document, host}: {vm: object; document: Document; host: GitHost}) {
329
374
  if (!(document.defaultView as PickerWindow)?.showDirectoryPicker) return null;
330
375
  const root = document.createElement('div');