@kubohiroya/turbowarp-plus-editor-git 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -10,7 +10,7 @@ TurboWarp Plus Editor のファイルメニューに、読み取り専用の「
10
10
 
11
11
  File System Access に対応する Chromium 系ブラウザーが必要です。初版は通常の `.git` ディレクトリとファイル内容の変更を扱います。linked worktree、サブモジュール、シンボリックリンクは対象外です。作業ツリーの実行権限の変更はブラウザーから検出できません。Git に記録済みのファイルモードは表示します。バイナリアセットはサイズを示し、テキスト差分の生成には上限があります。
12
12
 
13
- Node.js 26 で `npm ci` と `npm test` を実行します。Vite でライブラリとスタイルシート、TypeScript で型宣言を生成します。`npm pack` で Editor 用の配布物を作成します。Editor では既定で有効です。起動 URL に `twPlusLocalGit=0` を指定するとパネルの接続を解除できます。
13
+ Node.js 26 で `npm ci` と `npm test` を実行します。Vite でライブラリとスタイルシート、TypeScript で型宣言を生成します。`npm pack` で Editor 用の配布物を作成します。`npm run test:browser` は、最小 editor runtime(`@kubohiroya/turbowarp-plus-editor-test-runtime`)の上で、比較ダイアログのブロックの描画を Chromium で試します。Editor が固定する VM と Scratch Blocks を使い、Editor のビルドは要りません。Editor では既定で有効です。起動 URL に `twPlusLocalGit=0` を指定するとパネルの接続を解除できます。
14
14
 
15
15
  パネルの構成とローカル Git の扱いは [duff](https://github.com/chigichan24/duff) を参考にしました。実装には [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) と [react-diff-view](https://github.com/otakustay/react-diff-view) を使用し、同梱依存のライセンスはビルド時の第三者ライセンス一覧に収録します。
16
16
 
@@ -101,7 +101,7 @@ const diffFile = new Blob([patch], {type: 'text/x-diff'});
101
101
 
102
102
  `loadAsset` は `({version, md5ext, signal}) => Promise<Blob | null>` の形で、`version`(`before`/`after`)の作品フォルダの素材を読みます。渡すと、ステージ・スプライトの見出しにその版の現在のコスチューム(ステージは背景)のサムネイルを添えます。変更前後でコスチュームが違えば「変更前 → 変更後」と並べます。関数がないとき、または画像を読めないときは名前だけを示します。`createGitHubPullRequests` が返す作品ファイルには、変更前(マージベース)と変更後(head)の素材を raw.githubusercontent.com から読む `loadAsset` が付きます。この読み込みは GitHub API の利用上限を消費せず、1 ファイル 10 MiB までです。
103
103
 
104
- 確認済みの印は表示中の比較データに紐づくローカル状態です。PR 本文・ファイル差分の取得と GitHub へのレビュー送信は、呼び出し側が担当します。
104
+ 確認済みの印は、`rememberAs`(`owner/repo#42` など)を渡すと、ブラウザーのストレージにコードの内容と組で保存します。パネルを閉じても Editor を再読み込みしても残り、PR の head が変わると、内容が変わったコードの印だけが外れます。作品ファイルと Git には書きません。`rememberAs` がないときは、表示中のレビューだけの状態です。ローカル Git パネルの PR 一覧は、リポジトリ名と PR 番号を渡します。PR 本文・ファイル差分の取得と GitHub へのレビュー送信は、呼び出し側が担当します。
105
105
 
106
106
  ### 2 つの版を直接比べる
107
107
 
package/README.md CHANGED
@@ -4,13 +4,13 @@
4
4
 
5
5
  Local Git adds a read-only panel to the TurboWarp Plus Editor File menu. Select the repository root once, then browse to an sb3-toolchain v1 project folder inside it. Project editing and saving are supplied by the Editor's project-folder integration.
6
6
 
7
- The panel separates staged, unstaged, and untracked changes. It shows the current branch, HEAD, local branches, and the latest 50 commits. Select a file to inspect its text diff or binary size. When the host supplies a block renderer, project data (`project.source.json`) is compared as blocks by stage, sprite and code, and **Text** switches to the line diff. Each stage and sprite heading shows a thumbnail of its current costume (the backdrop for the stage) in each version. Assets are read from the compared versions: HEAD and the index for staged changes, the index and the working tree for unstaged changes, the working tree for untracked files, and the first parent and the commit for history. The assets folder is the `assetsDirectory` of that version's `sb3-source.json` (`assets` by default). When an asset cannot be read, only the name is shown. Pull request reviews read the same thumbnails from raw.githubusercontent.com at the merge base and head, without using the GitHub API rate limit. Select a commit to compare its files with its first parent. The selected project limits the file list and diffs to that folder.
7
+ The panel separates staged, unstaged, and untracked changes. It shows the current branch, HEAD, local branches, and the latest 50 commits. Select a file to inspect its text diff or binary size. When the host supplies a block renderer, project data (`project.source.json`) is compared as blocks by stage, sprite and code, and **Text** switches to the line diff. Each stage and sprite heading shows a thumbnail of its current costume (the backdrop for the stage) in each version. Assets are read from the compared versions: HEAD and the index for staged changes, the index and the working tree for unstaged changes, the working tree for untracked files, and the first parent and the commit for history. The assets folder is the `assetsDirectory` of that version's `sb3-source.json` (`assets` by default). When an asset cannot be read, only the name is shown. Pull request reviews read the same thumbnails from raw.githubusercontent.com at the merge base and head, without using the GitHub API rate limit. A review's **Reviewed** marks are kept in browser storage per repository and pull request, together with each script's content: they survive closing the panel and reloading the Editor, and only the marks of scripts whose content changed are cleared when the pull request head moves. They are never written to project files or Git. Select a commit to compare its files with its first parent. The selected project limits the file list and diffs to that folder.
8
8
 
9
9
  While the panel is open, it checks the repository every two seconds, when the window regains focus, and on `FileSystemObserver` notifications where available. It compares the sizes and modification times of HEAD, the index, `packed-refs`, `config`, the current branch ref, and the files in the selected project folder (the whole work tree when no project is selected, up to 5,000 files). When something changed and no diff or commit is open, the panel reads the repository again. While a diff, a commit or a pull request review is open, it keeps the view and asks you to **Refresh** instead. Checks wait while Git holds a lock on its metadata. **Reload Project** opens the selected project again using the Editor's unsaved-edit confirmation. Git inspection uses a read-only adapter and isomorphic-git with index refresh disabled. If the repository changes during a read, refresh after the other operation completes.
10
10
 
11
11
  A Chromium-based browser with File System Access support is required. The first version supports ordinary `.git` directories and content changes. Linked worktrees, submodules, and symbolic links are outside this scope. The browser cannot detect working-tree executable-bit changes. Recorded index and tree modes remain visible. Binary assets show sizes, and text diff generation is bounded.
12
12
 
13
- Use Node.js 26, run `npm ci`, then `npm test`. Vite builds the library and stylesheet, and TypeScript emits declarations. `npm pack` prepares the artifact used by the Editor build. The Editor enables the panel by default. Start it with `twPlusLocalGit=0` in its URL to disable the integration.
13
+ Use Node.js 26, run `npm ci`, then `npm test`. Vite builds the library and stylesheet, and TypeScript emits declarations. `npm pack` prepares the artifact used by the Editor build. `npm run test:browser` runs the comparison dialog's block drawing in Chromium on the minimal editor runtime (`@kubohiroya/turbowarp-plus-editor-test-runtime`), with the Editor's pinned VM and Scratch Blocks and without building the Editor. The Editor enables the panel by default. Start it with `twPlusLocalGit=0` in its URL to disable the integration.
14
14
 
15
15
  The panel structure and local Git approach were informed by [duff](https://github.com/chigichan24/duff). The implementation uses [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) and [react-diff-view](https://github.com/otakustay/react-diff-view). Bundled licenses are listed in the build's third-party notices.
16
16
 
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { S as parseProjectSource, a as PullRequestError, c as ProjectSourceComparison, d as organizeProjectChanges, f as projectPropertyLabel, i as createScratchBlocksDiffLocator, l as ProjectSourceReview, n as openProjectComparison, o as createGitHubPullRequests, r as createScratchBlocksDiffRenderer, s as gitHubRepositoryFromOrigin, v as useAssetImage, y as useCostumeImage } from "./project-comparison-D1rHzlCw.js";
1
+ import { S as parseProjectSource, a as PullRequestError, c as ProjectSourceComparison, d as organizeProjectChanges, f as projectPropertyLabel, i as createScratchBlocksDiffLocator, l as ProjectSourceReview, n as openProjectComparison, o as createGitHubPullRequests, r as createScratchBlocksDiffRenderer, s as gitHubRepositoryFromOrigin, v as useAssetImage, y as useCostumeImage } from "./project-comparison-LAyP_beL.js";
2
2
  import { n as init_objectSpread2, r as __commonJSMin, t as _objectSpread2$1 } from "./objectSpread2-Dkd68nJH.js";
3
3
  import { i as createReadOnlyFs, r as GitPanelError, t as openRepository } from "./repository-CtvWYPQ8.js";
4
4
  import * as m from "react";
@@ -3437,7 +3437,8 @@ function PullRequests({ name, labels, locale, renderBlock, locateFor }) {
3437
3437
  description: pr.description,
3438
3438
  files: locateFor ? pr.files.map((file) => "patch" in file ? _objectSpread2$1(_objectSpread2$1({}, file), {}, { locateBlock: locateFor(file.path, pr.head.sha) }) : file) : pr.files,
3439
3439
  renderBlock,
3440
- locale
3440
+ locale,
3441
+ rememberAs: `${name}#${pr.number}`
3441
3442
  }))));
3442
3443
  }
3443
3444
  function PullRequestReviewPanel({ repository, locale, renderBlock, locateFor }) {
@@ -3573,26 +3574,25 @@ function GitPanel({ vm, document, host, controller }) {
3573
3574
  });
3574
3575
  running.current = true;
3575
3576
  const ticket = ++epoch.current;
3576
- setState((previous) => automatic ? _objectSpread2$1(_objectSpread2$1({}, previous), {}, { busy: true }) : _objectSpread2$1(_objectSpread2$1({}, previous), {}, {
3577
+ if (!automatic) setState((previous) => _objectSpread2$1(_objectSpread2$1({}, previous), {}, {
3577
3578
  busy: true,
3578
3579
  error: null,
3579
3580
  notice: void 0
3580
3581
  }));
3581
3582
  try {
3582
3583
  const update = await operation();
3583
- if (epoch.current === ticket) setState((previous) => _objectSpread2$1(_objectSpread2$1(_objectSpread2$1({}, previous), update), {}, { busy: false }));
3584
+ if (epoch.current === ticket) setState((previous) => _objectSpread2$1(_objectSpread2$1(_objectSpread2$1({}, previous), update), {}, { busy: automatic ? previous.busy : false }));
3584
3585
  } catch (error) {
3585
3586
  if (epoch.current !== ticket) return;
3586
3587
  const code = error instanceof GitPanelError ? error.code : "failed";
3587
3588
  if (automatic && code === "changed") {
3588
3589
  handled.current = null;
3589
- setState((previous) => _objectSpread2$1(_objectSpread2$1({}, previous), {}, { busy: false }));
3590
3590
  return;
3591
3591
  }
3592
3592
  const message = labels[code] || labels.failed;
3593
3593
  setState((previous) => _objectSpread2$1(_objectSpread2$1({}, previous), {}, {
3594
3594
  error: automatic && previous.error ? previous.error : message,
3595
- busy: false,
3595
+ busy: automatic ? previous.busy : false,
3596
3596
  diff: null
3597
3597
  }));
3598
3598
  } finally {
@@ -1652,12 +1652,7 @@ function ComparisonTargets({ comparison, labels, renderBlock, loadAsset, locateB
1652
1652
  labels,
1653
1653
  loadAsset
1654
1654
  }), target.isStage ? labels.stage : target.name, target.scripts.length > 0 && /* @__PURE__ */ React.createElement("small", null, labels.scriptCount(target.scripts.length))), target.status !== "updated" && /* @__PURE__ */ React.createElement("p", { className: "twgit-review-target-status" }, (target.status === "added" ? labels.targetAdded : labels.targetRemoved)(target.name, target.isStage)), target.status === "updated" && target.properties.length > 0 && /* @__PURE__ */ React.createElement("p", null, labels.properties, " · ", propertyNames(target.properties, labels)), target.scripts.map((script) => {
1655
- const key = JSON.stringify([
1656
- review === null || review === void 0 ? void 0 : review.file,
1657
- target.isStage,
1658
- target.name,
1659
- script.id
1660
- ]);
1655
+ const key = reviewKey(review === null || review === void 0 ? void 0 : review.file, target, script);
1661
1656
  return /* @__PURE__ */ React.createElement(ScriptComparison, {
1662
1657
  key,
1663
1658
  script,
@@ -1755,7 +1750,54 @@ function ProjectSourceComparison({ before, after, renderBlock, locale = "en", fa
1755
1750
  locateBlock
1756
1751
  }));
1757
1752
  }
1758
- function ProjectSourceReview({ title, description, verification, files, renderBlock, locale = "en" }) {
1753
+ var reviewKey = (file, target, script) => JSON.stringify([
1754
+ file,
1755
+ target.isStage,
1756
+ target.name,
1757
+ script.id
1758
+ ]);
1759
+ var fingerprint = (value) => {
1760
+ let hash = 2166136261;
1761
+ for (const char of JSON.stringify(value)) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
1762
+ return (hash >>> 0).toString(36);
1763
+ };
1764
+ var scriptBlocks = ({ target, id }) => {
1765
+ const blocks = target.blocks;
1766
+ const rootOf = (start) => {
1767
+ let current = start;
1768
+ for (let guard = 0; guard < 1e4; guard++) {
1769
+ var _blocks$current;
1770
+ const parent = (_blocks$current = blocks[current]) === null || _blocks$current === void 0 ? void 0 : _blocks$current.parent;
1771
+ if (typeof parent !== "string" || !blocks[parent]) return current;
1772
+ current = parent;
1773
+ }
1774
+ return current;
1775
+ };
1776
+ return Object.keys(blocks).filter((key) => rootOf(key) === id).sort().map((key) => [key, blocks[key]]);
1777
+ };
1778
+ var scriptPrint = (script) => fingerprint([
1779
+ script.kind,
1780
+ script.before.map(scriptBlocks),
1781
+ script.after.map(scriptBlocks)
1782
+ ]);
1783
+ var storageKey = (name) => `turbowarp-plus-git:reviewed:${name}`;
1784
+ var readMarks = (name) => {
1785
+ try {
1786
+ var _globalThis$localStor, _globalThis$localStor2;
1787
+ const value = JSON.parse((_globalThis$localStor = (_globalThis$localStor2 = globalThis.localStorage) === null || _globalThis$localStor2 === void 0 ? void 0 : _globalThis$localStor2.getItem(storageKey(name))) !== null && _globalThis$localStor !== void 0 ? _globalThis$localStor : "{}");
1788
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1789
+ } catch (_unused3) {
1790
+ return {};
1791
+ }
1792
+ };
1793
+ var writeMarks = (name, marks) => {
1794
+ try {
1795
+ var _globalThis$localStor3, _globalThis$localStor4;
1796
+ if (Object.keys(marks).length) (_globalThis$localStor3 = globalThis.localStorage) === null || _globalThis$localStor3 === void 0 || _globalThis$localStor3.setItem(storageKey(name), JSON.stringify(marks));
1797
+ else (_globalThis$localStor4 = globalThis.localStorage) === null || _globalThis$localStor4 === void 0 || _globalThis$localStor4.removeItem(storageKey(name));
1798
+ } catch (_unused4) {}
1799
+ };
1800
+ function ProjectSourceReview({ title, description, verification, files, renderBlock, locale = "en", rememberAs }) {
1759
1801
  const labels = locale.startsWith("ja") ? ja : en;
1760
1802
  const entries = useMemo(() => files.map((file) => {
1761
1803
  if (!("patch" in file)) return { file };
@@ -1768,22 +1810,34 @@ function ProjectSourceReview({ title, description, verification, files, renderBl
1768
1810
  properties: []
1769
1811
  }
1770
1812
  };
1771
- } catch (_unused3) {
1813
+ } catch (_unused5) {
1772
1814
  return {
1773
1815
  file,
1774
1816
  invalid: true
1775
1817
  };
1776
1818
  }
1777
1819
  }), [files]);
1820
+ const scriptPrints = useMemo(() => {
1821
+ const prints = /* @__PURE__ */ new Map();
1822
+ for (const { file, comparison } of entries) {
1823
+ var _comparison$targets;
1824
+ for (const target of (_comparison$targets = comparison === null || comparison === void 0 ? void 0 : comparison.targets) !== null && _comparison$targets !== void 0 ? _comparison$targets : []) for (const script of target.scripts) prints.set(reviewKey(file.path, target, script), scriptPrint(script));
1825
+ }
1826
+ return prints;
1827
+ }, [entries]);
1778
1828
  const [checked, setChecked] = useState();
1779
- const reviewed = (checked === null || checked === void 0 ? void 0 : checked.entries) === entries ? checked.keys : /* @__PURE__ */ new Set();
1829
+ const marks = (checked === null || checked === void 0 ? void 0 : checked.entries) === entries ? checked.marks : rememberAs ? readMarks(rememberAs) : {};
1830
+ const reviewed = new Set([...scriptPrints].filter(([key, print]) => marks[key] === print).map(([key]) => key));
1780
1831
  const setReviewed = (key, value) => setChecked((previous) => {
1781
- const keys = new Set((previous === null || previous === void 0 ? void 0 : previous.entries) === entries ? previous.keys : []);
1782
- if (value) keys.add(key);
1783
- else keys.delete(key);
1832
+ const current = (previous === null || previous === void 0 ? void 0 : previous.entries) === entries ? previous.marks : rememberAs ? readMarks(rememberAs) : {};
1833
+ const next = {};
1834
+ for (const [name, print] of scriptPrints) if (current[name] === print) next[name] = print;
1835
+ if (value) next[key] = scriptPrints.get(key);
1836
+ else delete next[key];
1837
+ if (rememberAs) writeMarks(rememberAs, next);
1784
1838
  return {
1785
1839
  entries,
1786
- keys
1840
+ marks: next
1787
1841
  };
1788
1842
  });
1789
1843
  const targets = entries.reduce((total, entry) => {
@@ -1,4 +1,4 @@
1
- import { C as readProjectSourceDiff, _ as currentCostume, b as compareProjectBlocks, c as ProjectSourceComparison, d as organizeProjectChanges, f as projectPropertyLabel, g as usePreviewSync, h as createPreviewSync, i as createScratchBlocksDiffLocator, l as ProjectSourceReview, m as BlockPreview, n as openProjectComparison, o as createGitHubPullRequests, p as compareProjectForReview, r as createScratchBlocksDiffRenderer, s as gitHubRepositoryFromOrigin, t as ProjectComparisonDialog, u as assetFileName, x as createProjectSourceDiff } from "./project-comparison-D1rHzlCw.js";
1
+ import { C as readProjectSourceDiff, _ as currentCostume, b as compareProjectBlocks, c as ProjectSourceComparison, d as organizeProjectChanges, f as projectPropertyLabel, g as usePreviewSync, h as createPreviewSync, i as createScratchBlocksDiffLocator, l as ProjectSourceReview, m as BlockPreview, n as openProjectComparison, o as createGitHubPullRequests, p as compareProjectForReview, r as createScratchBlocksDiffRenderer, s as gitHubRepositoryFromOrigin, t as ProjectComparisonDialog, u as assetFileName, x as createProjectSourceDiff } from "./project-comparison-LAyP_beL.js";
2
2
  import { n as init_objectSpread2, t as _objectSpread2 } from "./objectSpread2-Dkd68nJH.js";
3
3
  import React, { useEffect, useMemo, useRef, useState } from "react";
4
4
  // src/project-source-diff.tsx
@@ -23,6 +23,11 @@ export interface ProjectSourceReviewProps {
23
23
  files: readonly ProjectSourceReviewFile[];
24
24
  renderBlock: ProjectBlockRenderer;
25
25
  locale?: string;
26
+ /**
27
+ * Where the reviewed marks are kept between reviews, such as `owner/repo#42`. Marks are stored in the browser with
28
+ * the content of each script, so a mark stays until that script changes. Without it, marks last while the review is open.
29
+ */
30
+ rememberAs?: string;
26
31
  }
27
32
  export interface ProjectSourceComparisonProps {
28
33
  /** The earlier project.source.json, or `null` when the file did not exist yet. */
@@ -45,4 +50,4 @@ export interface ProjectSourceComparisonProps {
45
50
  }
46
51
  /** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
47
52
  export declare function ProjectSourceComparison({ before, after, renderBlock, locale, fallback, loadAsset, locateBlock, target }: ProjectSourceComparisonProps): React.JSX.Element;
48
- export declare function ProjectSourceReview({ title, description, verification, files, renderBlock, locale }: ProjectSourceReviewProps): React.JSX.Element;
53
+ export declare function ProjectSourceReview({ title, description, verification, files, renderBlock, locale, rememberAs }: ProjectSourceReviewProps): React.JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-git",
3
- "version": "0.13.1",
3
+ "version": "0.14.1",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -46,7 +46,8 @@
46
46
  "scripts": {
47
47
  "build": "vite build && tsc -p tsconfig.json",
48
48
  "test": "npm run build && node --test test/*.test.js",
49
- "prepack": "npm run build"
49
+ "prepack": "npm run build",
50
+ "test:browser": "npm run build && node --test test/*.browser.js"
50
51
  },
51
52
  "peerDependencies": {
52
53
  "@kubohiroya/turbowarp-plus-editor-auth": ">=0.3.0 <0.4.0",
@@ -60,6 +61,8 @@
60
61
  },
61
62
  "devDependencies": {
62
63
  "@kubohiroya/turbowarp-plus-editor-auth": "0.3.0",
64
+ "@kubohiroya/turbowarp-plus-editor-test-runtime": "0.1.0",
65
+ "@playwright/test": "1.64.1",
63
66
  "@types/node": "24.19.2",
64
67
  "@types/react": "16.14.70",
65
68
  "@types/react-dom": "16.9.25",
package/src/index.tsx CHANGED
@@ -112,22 +112,24 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
112
112
  if (automatic) background.current = new Promise(resolve => {settle = resolve;});
113
113
  running.current = true;
114
114
  const ticket = ++epoch.current;
115
- // An automatic refresh keeps the current error and notice, such as an uncertain push result.
116
- setState(previous => automatic ? {...previous, busy: true} : {...previous, busy: true, error: null, notice: undefined});
115
+ // An automatic refresh keeps the current error and notice, such as an uncertain push result. It also
116
+ // leaves the controls enabled: an operation clicked meanwhile waits for it above, whereas a button
117
+ // that turned disabled under the pointer would swallow the click.
118
+ if (!automatic) setState(previous => ({...previous, busy: true, error: null, notice: undefined}));
117
119
  try {
118
120
  const update = await operation();
119
- if (epoch.current === ticket) setState(previous => ({...previous, ...update, busy: false}));
121
+ if (epoch.current === ticket) setState(previous => ({...previous, ...update, busy: automatic ? previous.busy : false}));
120
122
  } catch (error) {
121
123
  if (epoch.current !== ticket) return;
122
124
  const code = error instanceof GitPanelError ? error.code : 'failed';
123
125
  // A background read that met another tool still writing tries again on the next check, without an alert.
124
126
  if (automatic && code === 'changed') {
125
127
  handled.current = null;
126
- setState(previous => ({...previous, busy: false}));
127
128
  return;
128
129
  }
129
130
  const message = labels[code as keyof typeof labels] || labels.failed;
130
- setState(previous => ({...previous, error: automatic && previous.error ? previous.error : message, busy: false, diff: null}));
131
+ setState(previous => ({...previous, error: automatic && previous.error ? previous.error : message,
132
+ busy: automatic ? previous.busy : false, diff: null}));
131
133
  } finally {
132
134
  running.current = false;
133
135
  // A result dropped by a newer load still ends the busy state, unless a refresh follows at once.
@@ -30,6 +30,11 @@ export interface ProjectSourceReviewProps {
30
30
  files: readonly ProjectSourceReviewFile[];
31
31
  renderBlock: ProjectBlockRenderer;
32
32
  locale?: string;
33
+ /**
34
+ * Where the reviewed marks are kept between reviews, such as `owner/repo#42`. Marks are stored in the browser with
35
+ * the content of each script, so a mark stays until that script changes. Without it, marks last while the review is open.
36
+ */
37
+ rememberAs?: string;
33
38
  }
34
39
 
35
40
  // Like the Editor's Code tab, a connected group of blocks is called code, and each item is named by its hat block.
@@ -208,7 +213,7 @@ function ComparisonTargets({comparison, labels, renderBlock, loadAsset, locateBl
208
213
  {(target.status === 'added' ? labels.targetAdded : labels.targetRemoved)(target.name, target.isStage)}</p>}
209
214
  {target.status === 'updated' && target.properties.length > 0 && <p>{labels.properties} · {propertyNames(target.properties, labels)}</p>}
210
215
  {target.scripts.map(script => {
211
- const key = JSON.stringify([review?.file, target.isStage, target.name, script.id]);
216
+ const key = reviewKey(review?.file, target, script);
212
217
  return <ScriptComparison key={key} script={script} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
213
218
  location={location} reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}/>;
214
219
  })}
@@ -310,7 +315,49 @@ export function ProjectSourceComparison({before, after, renderBlock, locale = 'e
310
315
  </section>;
311
316
  }
312
317
 
313
- export function ProjectSourceReview({title, description, verification, files, renderBlock, locale = 'en'}: ProjectSourceReviewProps) {
318
+ const reviewKey = (file: string | undefined, target: TargetReview, script: ScriptReview) =>
319
+ JSON.stringify([file, target.isStage, target.name, script.id]);
320
+ // A short fingerprint of what a script shows, so a stored mark is dropped once the script changes.
321
+ const fingerprint = (value: unknown) => {
322
+ let hash = 0x811c9dc5;
323
+ for (const char of JSON.stringify(value)) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193);
324
+ return (hash >>> 0).toString(36);
325
+ };
326
+ // A side carries its whole stage or sprite; only the blocks of its own script decide whether a mark still holds.
327
+ const scriptBlocks = ({target, id}: ScriptReview['before'][number]) => {
328
+ const blocks = target.blocks as Record<string, {parent?: unknown} | undefined>;
329
+ const rootOf = (start: string) => {
330
+ let current = start;
331
+ for (let guard = 0; guard < 10000; guard++) {
332
+ const parent = blocks[current]?.parent;
333
+ if (typeof parent !== 'string' || !blocks[parent]) return current;
334
+ current = parent;
335
+ }
336
+ return current;
337
+ };
338
+ return Object.keys(blocks).filter(key => rootOf(key) === id).sort().map(key => [key, blocks[key]]);
339
+ };
340
+ const scriptPrint = (script: ScriptReview) => fingerprint([script.kind, script.before.map(scriptBlocks), script.after.map(scriptBlocks)]);
341
+ const storageKey = (name: string) => `turbowarp-plus-git:reviewed:${name}`;
342
+ // Browser storage can be missing or refuse access; the marks then last while the review is open.
343
+ const readMarks = (name: string): Record<string, string> => {
344
+ try {
345
+ const value = JSON.parse(globalThis.localStorage?.getItem(storageKey(name)) ?? '{}');
346
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
347
+ } catch {
348
+ return {};
349
+ }
350
+ };
351
+ const writeMarks = (name: string, marks: Record<string, string>) => {
352
+ try {
353
+ if (Object.keys(marks).length) globalThis.localStorage?.setItem(storageKey(name), JSON.stringify(marks));
354
+ else globalThis.localStorage?.removeItem(storageKey(name));
355
+ } catch {
356
+ // Not kept; the marks still work for this review.
357
+ }
358
+ };
359
+
360
+ export function ProjectSourceReview({title, description, verification, files, renderBlock, locale = 'en', rememberAs}: ProjectSourceReviewProps) {
314
361
  const labels = locale.startsWith('ja') ? ja : en;
315
362
  const entries = useMemo(() => files.map(file => {
316
363
  if (!('patch' in file)) return {file};
@@ -321,13 +368,28 @@ export function ProjectSourceReview({title, description, verification, files, re
321
368
  return {file, invalid: true};
322
369
  }
323
370
  }), [files]);
324
- const [checked, setChecked] = useState<{entries: typeof entries; keys: Set<string>}>();
325
- const reviewed = checked?.entries === entries ? checked.keys : new Set<string>();
371
+ // Each script of this review by its mark key, with what it shows now.
372
+ const scriptPrints = useMemo(() => {
373
+ const prints = new Map<string, string>();
374
+ for (const {file, comparison} of entries) {
375
+ for (const target of comparison?.targets ?? []) {
376
+ for (const script of target.scripts) prints.set(reviewKey(file.path, target, script), scriptPrint(script));
377
+ }
378
+ }
379
+ return prints;
380
+ }, [entries]);
381
+ const [checked, setChecked] = useState<{entries: typeof entries; marks: Record<string, string>}>();
382
+ const marks = checked?.entries === entries ? checked.marks : rememberAs ? readMarks(rememberAs) : {};
383
+ const reviewed = new Set([...scriptPrints].filter(([key, print]) => marks[key] === print).map(([key]) => key));
326
384
  const setReviewed = (key: string, value: boolean) => setChecked(previous => {
327
- const keys = new Set(previous?.entries === entries ? previous.keys : []);
328
- if (value) keys.add(key);
329
- else keys.delete(key);
330
- return {entries, keys};
385
+ const current = previous?.entries === entries ? previous.marks : rememberAs ? readMarks(rememberAs) : {};
386
+ // Keep only marks that still match a script of this review.
387
+ const next: Record<string, string> = {};
388
+ for (const [name, print] of scriptPrints) if (current[name] === print) next[name] = print;
389
+ if (value) next[key] = scriptPrints.get(key)!;
390
+ else delete next[key];
391
+ if (rememberAs) writeMarks(rememberAs, next);
392
+ return {entries, marks: next};
331
393
  });
332
394
  const targets = entries.reduce((total, entry) => total + (entry.comparison?.targets.length ?? 0), 0);
333
395
  const scripts = entries.reduce((total, entry) => total + (entry.comparison ? countScripts(entry.comparison) : 0), 0);
@@ -111,7 +111,7 @@ function PullRequests({name, labels, locale, renderBlock, locateFor}: {
111
111
  <a href={pr.url} target="_blank" rel="noreferrer">{labels.onGitHub}</a>
112
112
  </p>
113
113
  <ProjectSourceReview title={pr.title} description={pr.description} files={locateFor ? pr.files.map(file => 'patch' in file ? {...file, locateBlock: locateFor(file.path, pr.head.sha)} : file) : pr.files}
114
- renderBlock={renderBlock} locale={locale}/>
114
+ renderBlock={renderBlock} locale={locale} rememberAs={`${name}#${pr.number}`}/>
115
115
  </>}
116
116
  </main>
117
117
  </div>;