@kubohiroya/turbowarp-plus-editor-git 0.7.0 → 0.8.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.
@@ -4,20 +4,46 @@ interface BlockWorkspace {
4
4
  dispose(): void;
5
5
  markFocused(): void;
6
6
  getBlocksBoundingBox(): {
7
+ x?: number;
8
+ y?: number;
7
9
  width: number;
8
10
  height: number;
9
11
  };
10
12
  getBlockById(id: string): DiffSvgBlock | null;
11
13
  options?: {
12
14
  pathToMedia?: string;
15
+ zoomOptions?: {
16
+ startScale?: number;
17
+ };
18
+ };
19
+ scale?: number;
20
+ scrollX?: number;
21
+ scrollY?: number;
22
+ translate?(x: number, y: number): void;
23
+ getMetrics?(): {
24
+ absoluteLeft?: number;
25
+ absoluteTop?: number;
26
+ } | null;
27
+ scrollbar?: {
28
+ resize(): void;
29
+ } | null;
30
+ }
31
+ interface InjectOptions {
32
+ readOnly: true;
33
+ sounds: false;
34
+ media?: string;
35
+ scrollbars?: boolean;
36
+ zoom?: {
37
+ controls: boolean;
38
+ wheel: boolean;
39
+ startScale: number;
40
+ maxScale: number;
41
+ minScale: number;
42
+ scaleSpeed: number;
13
43
  };
14
44
  }
15
45
  export interface ScratchBlocksPort {
16
- inject(container: HTMLElement, options: {
17
- readOnly: true;
18
- sounds: false;
19
- media?: string;
20
- }): BlockWorkspace;
46
+ inject(container: HTMLElement, options: InjectOptions): BlockWorkspace;
21
47
  svgResize(workspace: BlockWorkspace): void;
22
48
  getMainWorkspace(): BlockWorkspace | null;
23
49
  mainWorkspace: BlockWorkspace | null;
@@ -47,19 +73,56 @@ export interface ScratchBlockDiffRendererOptions {
47
73
  }, deferProjectChanged: true): void;
48
74
  blockToXML(id: string): string | undefined;
49
75
  };
76
+ /**
77
+ * Adds the host's workspace navigation to each preview, as in its own code area: for example the costume
78
+ * thumbnail, a minimap and a row of zoom buttons in the lower right. `watermark` holds the costume thumbnail when
79
+ * the version has one; the host positions it. Without this option, previews keep Scratch Blocks' zoom buttons.
80
+ */
81
+ attachNavigation?(workspace: unknown, blockly: ScratchBlocksPort, watermark?: HTMLElement): {
82
+ dispose(): void;
83
+ } | null | undefined;
50
84
  }
51
85
  export interface ScriptBlockMark {
52
86
  id: string;
53
87
  kind: BlockChangeKind;
54
88
  parts: readonly BlockPart[];
55
89
  }
90
+ /** Position and zoom of one preview, in Scratch Blocks workspace terms. */
91
+ export interface BlockPreviewViewport {
92
+ scale: number;
93
+ x: number;
94
+ y: number;
95
+ }
96
+ /** Lets several previews of one comparison share their zoom, scroll position and height. */
97
+ export interface BlockPreviewView {
98
+ /** The zoom and frame height at which this preview shows all of its blocks. */
99
+ readonly fit: {
100
+ scale: number;
101
+ height: number;
102
+ };
103
+ /** Applies a zoom and frame height, scrolling to the start of the blocks. */
104
+ layout(fit: {
105
+ scale: number;
106
+ height: number;
107
+ }): void;
108
+ viewport(): BlockPreviewViewport;
109
+ setViewport(viewport: BlockPreviewViewport): void;
110
+ /** Calls `listener` after the user scrolls or zooms this preview. */
111
+ onChange(listener: () => void): () => void;
112
+ }
113
+ export type ProjectBlockRendering = (() => void) | {
114
+ dispose(): void;
115
+ view?: BlockPreviewView;
116
+ } | undefined;
56
117
  export type ProjectBlockRenderer = (args: BlockSide & {
57
118
  container: HTMLElement;
58
119
  withContext: boolean;
59
120
  parts?: readonly BlockPart[];
60
121
  marks?: readonly ScriptBlockMark[];
61
122
  signal: AbortSignal;
62
- }) => Promise<(() => void) | undefined>;
123
+ /** The costume thumbnail element of this version, placed in the preview's lower right by the host navigation. */
124
+ watermark?: HTMLElement;
125
+ }) => Promise<ProjectBlockRendering>;
63
126
  /** Use the host's Scratch VM serializer and Scratch Blocks definitions in an isolated workspace. */
64
127
  export declare function createScratchBlocksDiffRenderer(options: ScratchBlockDiffRendererOptions): ProjectBlockRenderer;
65
128
  export {};
package/dist/style.css CHANGED
@@ -22,8 +22,26 @@
22
22
  .twgit-block-diff-sides { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
23
23
  .twgit-block-diff-side { padding: 10px 14px; min-width: 0; }
24
24
  .twgit-block-diff-side + .twgit-block-diff-side { border-inline-start: 1px solid color-mix(in srgb, CanvasText 20%, Canvas); }
25
- .twgit-block-diff-preview { overflow: auto; max-height: 420px; }
26
- .twgit-block-diff-surface { position: relative; min-width: 100%; height: 64px; }
25
+ /* A preview is a small workspace: it scrolls and zooms inside its frame, which people can make taller. */
26
+ .twgit-block-diff-preview {
27
+ position: relative;
28
+ overflow: hidden;
29
+ resize: vertical;
30
+ min-height: 120px;
31
+ max-height: 85vh;
32
+ border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas);
33
+ border-radius: 6px;
34
+ }
35
+ .twgit-block-diff-surface { position: absolute; inset: 0; }
36
+ .twgit-block-diff-watermark { position: absolute; z-index: 2; top: 10px; right: 12px; pointer-events: none; }
37
+ .twgit-block-diff-watermark[data-navigation] { top: auto; right: var(--plus-watermark-right, 12px); bottom: var(--plus-watermark-bottom, 140px); }
38
+ .twgit-block-diff-watermark > img {
39
+ display: block;
40
+ max-width: var(--plus-watermark-size, 48px);
41
+ max-height: var(--plus-watermark-size, 48px);
42
+ opacity: .6;
43
+ }
44
+ .twgit-block-diff-link { display: block; padding: 6px 12px 0; }
27
45
  /* These markers also apply to the live Editor workspace returned by the host. */
28
46
  .twgit-changed-block > .blocklyPath { stroke: var(--twgit-change, #635bce); stroke-width: 2.5px; }
29
47
  .twgit-changed-block-part { outline: 2.5px solid #b4510a; outline-offset: 2px; }
@@ -77,6 +95,17 @@
77
95
  .twgit-pr-paging, .twgit-pr-revision { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
78
96
  .twgit-pr-revision code { overflow-wrap: anywhere; }
79
97
  .twgit-review .twgit-block-diff-side h4 { font-size: 1em; }
98
+
99
+ /* Costume (or backdrop) thumbnails in stage and sprite headings. */
100
+ .twgit-review-thumbnails { display: inline-flex; align-items: center; gap: 4px; margin-inline-end: 6px; vertical-align: middle; }
101
+ .twgit-review-thumbnails img {
102
+ width: 30px;
103
+ height: 30px;
104
+ object-fit: contain;
105
+ background: Canvas;
106
+ border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas));
107
+ border-radius: 4px;
108
+ }
80
109
  :root{--diff-background-color:initial;--diff-text-color:initial;--diff-font-family:Consolas,Courier,monospace;--diff-selection-background-color:#b3d7ff;--diff-selection-text-color:var(--diff-text-color);--diff-gutter-insert-background-color:#d6fedb;--diff-gutter-insert-text-color:var(--diff-text-color);--diff-gutter-delete-background-color:#fadde0;--diff-gutter-delete-text-color:var(--diff-text-color);--diff-gutter-selected-background-color:#fffce0;--diff-gutter-selected-text-color:var(--diff-text-color);--diff-code-insert-background-color:#eaffee;--diff-code-insert-text-color:var(--diff-text-color);--diff-code-delete-background-color:#fdeff0;--diff-code-delete-text-color:var(--diff-text-color);--diff-code-insert-edit-background-color:#c0dc91;--diff-code-insert-edit-text-color:var(--diff-text-color);--diff-code-delete-edit-background-color:#f39ea2;--diff-code-delete-edit-text-color:var(--diff-text-color);--diff-code-selected-background-color:#fffce0;--diff-code-selected-text-color:var(--diff-text-color);--diff-omit-gutter-line-color:#cb2a1d;}.diff{background-color:var(--diff-background-color);border-collapse:collapse;color:var(--diff-text-color);table-layout:fixed;width:100%;}.diff::-moz-selection{background-color:#b3d7ff;background-color:var(--diff-selection-background-color);color:var(--diff-text-color);color:var(--diff-selection-text-color);}.diff::selection{background-color:#b3d7ff;background-color:var(--diff-selection-background-color);color:var(--diff-text-color);color:var(--diff-selection-text-color);}.diff td{padding-bottom:0;padding-top:0;vertical-align:top;}.diff-line{font-family:Consolas,Courier,monospace;font-family:var(--diff-font-family);line-height:1.5;}.diff-gutter>a{color:inherit;display:block;}.diff-gutter{cursor:pointer;padding:0 1ch;text-align:right;-webkit-user-select:none;-moz-user-select:none;user-select:none;}.diff-gutter-insert{background-color:#d6fedb;background-color:var(--diff-gutter-insert-background-color);color:var(--diff-text-color);color:var(--diff-gutter-insert-text-color);}.diff-gutter-delete{background-color:#fadde0;background-color:var(--diff-gutter-delete-background-color);color:var(--diff-text-color);color:var(--diff-gutter-delete-text-color);}.diff-gutter-omit{cursor:default;}.diff-gutter-selected{background-color:#fffce0;background-color:var(--diff-gutter-selected-background-color);color:var(--diff-text-color);color:var(--diff-gutter-selected-text-color);}.diff-code{word-wrap:break-word;padding:0 0 0 .5em;white-space:pre-wrap;word-break:break-all;}.diff-code-edit{color:inherit;}.diff-code-insert{background-color:#eaffee;background-color:var(--diff-code-insert-background-color);color:var(--diff-text-color);color:var(--diff-code-insert-text-color);}.diff-code-insert .diff-code-edit{background-color:#c0dc91;background-color:var(--diff-code-insert-edit-background-color);color:var(--diff-text-color);color:var(--diff-code-insert-edit-text-color);}.diff-code-delete{background-color:#fdeff0;background-color:var(--diff-code-delete-background-color);color:var(--diff-text-color);color:var(--diff-code-delete-text-color);}.diff-code-delete .diff-code-edit{background-color:#f39ea2;background-color:var(--diff-code-delete-edit-background-color);color:var(--diff-text-color);color:var(--diff-code-delete-edit-text-color);}.diff-code-selected{background-color:#fffce0;background-color:var(--diff-code-selected-background-color);color:var(--diff-text-color);color:var(--diff-code-selected-text-color);}.diff-widget-content{vertical-align:top;}.diff-gutter-col{width:7ch;}.diff-gutter-omit{height:0;}.diff-gutter-omit:before{background-color:#cb2a1d;background-color:var(--diff-omit-gutter-line-color);content:" ";display:block;height:100%;margin-left:4.6ch;overflow:hidden;white-space:pre;width:2px;}.diff-decoration{line-height:1.5;-webkit-user-select:none;-moz-user-select:none;user-select:none;}.diff-decoration-content{font-family:Consolas,Courier,monospace;font-family:var(--diff-font-family);padding:0;}.twgit-backdrop { position: fixed; inset: 0; z-index: 65000; background: #0006; display: flex; align-items: center; justify-content: center; }
81
110
  .twgit-panel { width: 94vw; height: 90vh; display: flex; flex-direction: column; background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 8px; box-shadow: 0 12px 48px #0005; font: 14px/1.5 system-ui, sans-serif; overflow: hidden; }
82
111
  .twgit-panel > header { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid GrayText; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-git",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -35,8 +35,13 @@
35
35
  "vite.config.ts",
36
36
  "tsconfig.json",
37
37
  "README.ja.md",
38
- "THIRD_PARTY_NOTICES.md"
38
+ "THIRD_PARTY_NOTICES.md",
39
+ "README.md",
40
+ "LICENSE"
39
41
  ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
40
45
  "scripts": {
41
46
  "build": "vite build && tsc -p tsconfig.json",
42
47
  "test": "npm run build && node --test test/*.test.js",
@@ -1,34 +1,115 @@
1
- import React, {useEffect, useRef, useState} from 'react';
1
+ import React, {useEffect, useMemo, useRef, useState} from 'react';
2
2
  import type {BlockPart, BlockSide} from './project-source-patch.js';
3
- import type {ProjectBlockRenderer, ScriptBlockMark} from './scratch-block-diff-renderer.js';
3
+ import type {BlockPreviewView, ProjectBlockRenderer, ProjectBlockRendering, ScriptBlockMark} from './scratch-block-diff-renderer.js';
4
+ import {currentCostume, useCostumeImage} from './project-assets.js';
5
+ import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
4
6
 
5
7
  export type LocationStatus = 'locating' | 'located' | 'missingLocation';
6
8
  type PreviewLabels = Record<'absent' | 'loading' | 'unavailable', string> & Partial<Record<LocationStatus | 'locate', string>>;
7
9
 
8
- export function BlockPreview({side, title, parts, marks, renderBlock, withContext, labels, onLocate, locationStatus}: {
10
+ /** Keeps the previews of one comparison at the same zoom, height and scroll position while they are linked. */
11
+ export interface PreviewSync {
12
+ add(view: BlockPreviewView): () => void;
13
+ setLinked(linked: boolean): void;
14
+ }
15
+
16
+ export function createPreviewSync(): PreviewSync {
17
+ const members = new Map<BlockPreviewView, () => void>();
18
+ let linked = true;
19
+ let moving = false;
20
+ // The version that needs the smaller zoom and the taller frame decides, so neither one is cut off.
21
+ const align = () => {
22
+ if (!linked || !members.size) return;
23
+ const views = [...members.keys()];
24
+ const fit = {scale: Math.min(...views.map(view => view.fit.scale)), height: Math.max(...views.map(view => view.fit.height))};
25
+ for (const view of views) view.layout(fit);
26
+ };
27
+ return {
28
+ add(view) {
29
+ const stop = view.onChange(() => {
30
+ if (!linked || moving) return;
31
+ moving = true;
32
+ try {
33
+ const viewport = view.viewport();
34
+ for (const other of members.keys()) if (other !== view) other.setViewport(viewport);
35
+ } finally {
36
+ moving = false;
37
+ }
38
+ });
39
+ members.set(view, stop);
40
+ align();
41
+ return () => {
42
+ stop();
43
+ members.delete(view);
44
+ };
45
+ },
46
+ setLinked(value) {
47
+ if (linked === value) return;
48
+ linked = value;
49
+ align();
50
+ }
51
+ };
52
+ }
53
+
54
+ const linkLabels = {en: 'Move both versions together', ja: '変更前と変更後を連動して動かす'};
55
+
56
+ /** One sync group per comparison, with the checkbox that links or unlinks its previews. */
57
+ export function usePreviewSync(locale: string) {
58
+ const sync = useMemo(createPreviewSync, []);
59
+ const [linked, setLinked] = useState(true);
60
+ useEffect(() => sync.setLinked(linked), [sync, linked]);
61
+ const toggle = <label className="twgit-block-diff-link"><input type="checkbox" checked={linked}
62
+ onChange={event => setLinked(event.currentTarget.checked)}/> {locale.startsWith('ja') ? linkLabels.ja : linkLabels.en}</label>;
63
+ return {sync, toggle};
64
+ }
65
+
66
+ const rendering = (result: ProjectBlockRendering) =>
67
+ typeof result === 'function' ? {release: result} : {release: result && (() => result.dispose()), view: result?.view};
68
+
69
+ export function BlockPreview({side, title, parts, marks, renderBlock, withContext, labels, onLocate, locationStatus,
70
+ version, loadAsset, sync}: {
9
71
  side?: BlockSide; title: string; parts?: readonly BlockPart[]; marks?: readonly ScriptBlockMark[]; renderBlock: ProjectBlockRenderer;
10
72
  withContext: boolean; labels: PreviewLabels; onLocate?: () => void; locationStatus?: LocationStatus;
73
+ /** Which version this preview shows, for its costume thumbnail. */
74
+ version?: ProjectVersion; loadAsset?: ProjectAssetLoader; sync?: PreviewSync;
11
75
  }) {
12
76
  const host = useRef<HTMLDivElement>(null);
77
+ const watermark = useRef<HTMLDivElement>(null);
13
78
  const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>('loading');
79
+ const thumbnail = !!(side && version && loadAsset && currentCostume(side.target));
80
+ const image = useCostumeImage(thumbnail ? side!.target : undefined, version ?? 'after', loadAsset);
14
81
  useEffect(() => {
15
82
  if (!side || !host.current) return;
16
83
  const container = host.current;
17
84
  const abort = new AbortController();
18
85
  let dispose: (() => void) | undefined;
19
86
  setStatus('loading');
20
- void Promise.resolve().then(() => renderBlock({...side, container, withContext, parts, marks, signal: abort.signal})).then(cleanup => {
21
- if (abort.signal.aborted) cleanup?.();
22
- else {dispose = cleanup; setStatus('ready');}
87
+ void Promise.resolve().then(() => renderBlock({...side, container, withContext, parts, marks, signal: abort.signal,
88
+ watermark: thumbnail ? watermark.current ?? undefined : undefined})).then(result => {
89
+ const {release, view} = rendering(result);
90
+ if (abort.signal.aborted) {
91
+ release?.();
92
+ return;
93
+ }
94
+ const leave = view && sync?.add(view);
95
+ dispose = () => {
96
+ leave?.();
97
+ release?.();
98
+ };
99
+ setStatus('ready');
23
100
  }, () => {if (!abort.signal.aborted) setStatus('failed');});
24
101
  return () => {abort.abort(); dispose?.();};
25
- }, [side, renderBlock, withContext, parts, marks]);
102
+ }, [side, renderBlock, withContext, parts, marks, sync, thumbnail]);
26
103
  return <div className="twgit-block-diff-side">
27
104
  <h4>{title}</h4>
28
105
  {!side && <p>{labels.absent}</p>}
29
106
  {side && status === 'loading' && <p role="status">{labels.loading}</p>}
30
107
  {side && status === 'failed' && <p role="status">{labels.unavailable}</p>}
31
- <div ref={host} className="twgit-block-diff-preview"/>
108
+ {side && <div ref={host} className="twgit-block-diff-preview">
109
+ {thumbnail && <div ref={watermark} className="twgit-block-diff-watermark" data-dockview-costume-thumbnail="">
110
+ {image && <img src={image} alt=""/>}
111
+ </div>}
112
+ </div>}
32
113
  {side && onLocate && <button type="button" aria-label={`${title} · ${labels.locate}`}
33
114
  onClick={onLocate}>{labels.locate}</button>}
34
115
  {locationStatus && <p role="status">{labels[locationStatus]}</p>}
@@ -4,6 +4,7 @@ import {ProjectSourceComparison} from './project-source-review.js';
4
4
  import type {FileChange, FileDiff} from './repository.js';
5
5
  import type {labelsFor} from './messages.js';
6
6
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
7
+ import type {ProjectAssetLoader} from './project-assets.js';
7
8
 
8
9
  const en = {view: 'Show changes as', blocks: 'Blocks', text: 'Text'};
9
10
  const ja: typeof en = {view: '変更の表示', blocks: 'ブロック', text: 'テキスト'};
@@ -14,9 +15,9 @@ export const isProjectSource = (path: string) => path.split('/').pop() === 'proj
14
15
  /**
15
16
  * Shows one changed file. Project sources are compared as blocks by default, with their text diff one switch away.
16
17
  */
17
- export function FileDiffView({change, diff, labels, locale, renderBlock}: {
18
+ export function FileDiffView({change, diff, labels, locale, renderBlock, loadAsset}: {
18
19
  change: FileChange; diff: FileDiff | null; labels: ReturnType<typeof labelsFor>; locale: string;
19
- renderBlock?: ProjectBlockRenderer;
20
+ renderBlock?: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
20
21
  }) {
21
22
  const [view, setView] = useState<'blocks' | 'text'>('blocks');
22
23
  const names = locale.startsWith('ja') ? ja : en;
@@ -37,7 +38,7 @@ export function FileDiffView({change, diff, labels, locale, renderBlock}: {
37
38
  {(['blocks', 'text'] as const).map(mode => <button key={mode} type="button" aria-pressed={view === mode}
38
39
  data-git-view={mode} onClick={() => setView(mode)}>{names[mode]}</button>)}
39
40
  </div>}
40
- {sources && view === 'blocks' ? <ProjectSourceComparison renderBlock={renderBlock!} locale={locale} fallback={text}
41
+ {sources && view === 'blocks' ? <ProjectSourceComparison renderBlock={renderBlock!} locale={locale} fallback={text} loadAsset={loadAsset}
41
42
  before={change.before === null ? null : sources.before} after={change.after === null ? null : sources.after}/> : text}
42
43
  </>;
43
44
  }
@@ -1,5 +1,7 @@
1
1
  import {createProjectSourceDiff, isRecord} from './project-source-patch.js';
2
2
  import type {ProjectSourceReviewFile} from './project-source-review.js';
3
+ import {assetLimit, assetsDirectoryParts, folderSettingsLimit, isAssetName} from './project-asset-paths.js';
4
+ import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
3
5
 
4
6
  type ErrorCode = 'unavailable' | 'rateLimit' | 'failed' | 'changed' | 'tooManyFiles';
5
7
  export class PullRequestError extends Error {
@@ -24,19 +26,17 @@ const invalidResponse = () => new PullRequestError('failed');
24
26
  const emptyProject = '{"targets":[]}\n';
25
27
  const sourceLimit = 1024 * 1024;
26
28
 
27
- async function readText(response: Response, limit: number) {
29
+ async function readBody(response: Response, limit: number, chunk: (value: Uint8Array) => void) {
28
30
  if (Number(response.headers.get('Content-Length')) > limit || !response.body) throw invalidResponse();
29
31
  const reader = response.body.getReader();
30
- const decoder = new TextDecoder('utf-8', {fatal: true});
31
- const chunks: string[] = [];
32
32
  let length = 0;
33
33
  try {
34
34
  for (;;) {
35
35
  const {done, value} = await reader.read();
36
- if (done) return chunks.join('') + decoder.decode();
36
+ if (done) return;
37
37
  length += value.byteLength;
38
38
  if (length > limit) throw invalidResponse();
39
- chunks.push(decoder.decode(value, {stream: true}));
39
+ chunk(value);
40
40
  }
41
41
  } catch (error) {
42
42
  void reader.cancel().catch(() => {});
@@ -44,6 +44,13 @@ async function readText(response: Response, limit: number) {
44
44
  }
45
45
  }
46
46
 
47
+ async function readText(response: Response, limit: number) {
48
+ const decoder = new TextDecoder('utf-8', {fatal: true});
49
+ const chunks: string[] = [];
50
+ await readBody(response, limit, value => chunks.push(decoder.decode(value, {stream: true})));
51
+ return chunks.join('') + decoder.decode();
52
+ }
53
+
47
54
  function readSummary(value: unknown) {
48
55
  if (!isRecord(value) || !Number.isSafeInteger(value.number) || Number(value.number) < 1 ||
49
56
  typeof value.title !== 'string') throw invalidResponse();
@@ -80,6 +87,62 @@ function contentPath(path: string) {
80
87
  return parts.map(encodeURIComponent).join('/');
81
88
  }
82
89
 
90
+ /** One version of a project file on GitHub: the repository and commit, and the project file's path. */
91
+ interface ProjectLocation {repository: string; sha: string; path: string}
92
+
93
+ /**
94
+ * Reads the assets of both versions of a project file from raw.githubusercontent.com, which does not count against the
95
+ * API rate limit. Each version's sb3-source.json names its assets directory. Every read is capped, honors both its own
96
+ * signal and the pull request's, and resolves `null` on any failure.
97
+ */
98
+ function rawAssetLoader(versions: Record<ProjectVersion, ProjectLocation | null>, send: typeof fetch, review: AbortSignal): ProjectAssetLoader {
99
+ const directories = new Map<ProjectVersion, Promise<string[] | null>>();
100
+ const get = (url: string, signal: AbortSignal) => send(url, {credentials: 'omit', redirect: 'error', signal});
101
+ const folderUrl = ({repository, sha, path}: ProjectLocation) => 'https://raw.githubusercontent.com/' +
102
+ repository.split('/').map(encodeURIComponent).join('/') + '/' + sha + '/' +
103
+ contentPath(path).split('/').slice(0, -1).map(part => part + '/').join('');
104
+ const readDirectory = async (folder: string, signal: AbortSignal) => {
105
+ let settings: string | null = null;
106
+ try {
107
+ const response = await get(folder + 'sb3-source.json', signal);
108
+ if (response.ok) settings = await readText(response, folderSettingsLimit);
109
+ else void response.body?.cancel().catch(() => {});
110
+ } catch (error) {
111
+ // A missing or unreadable file falls back to `assets`; a cancelled read is retried by the next request.
112
+ if (signal.aborted) throw error;
113
+ }
114
+ return assetsDirectoryParts(settings);
115
+ };
116
+ return async ({version, md5ext, signal: own}) => {
117
+ const location = versions[version];
118
+ if (!location || !isAssetName(md5ext)) return null;
119
+ try {
120
+ const signal = AbortSignal.any([own, review]);
121
+ if (signal.aborted) return null;
122
+ const folder = folderUrl(location);
123
+ let directory = directories.get(version);
124
+ if (!directory) {
125
+ directory = readDirectory(folder, signal);
126
+ directories.set(version, directory);
127
+ const pending = directory;
128
+ pending.catch(() => {if (directories.get(version) === pending) directories.delete(version);});
129
+ }
130
+ const parts = await directory;
131
+ if (!parts || signal.aborted) return null;
132
+ const response = await get(folder + parts.map(encodeURIComponent).join('/') + '/' + md5ext, signal);
133
+ if (!response.ok) {
134
+ void response.body?.cancel().catch(() => {});
135
+ return null;
136
+ }
137
+ const chunks: Uint8Array<ArrayBuffer>[] = [];
138
+ await readBody(response, assetLimit, value => chunks.push(value as Uint8Array<ArrayBuffer>));
139
+ return signal.aborted ? null : new Blob(chunks);
140
+ } catch {
141
+ return null;
142
+ }
143
+ };
144
+ }
145
+
83
146
  export function createGitHubPullRequests(repository: string, send: typeof fetch = fetch) {
84
147
  if (!validRepository(repository)) throw invalidResponse();
85
148
  const api = 'https://api.github.com';
@@ -148,7 +211,11 @@ export function createGitHubPullRequests(repository: string, send: typeof fetch
148
211
  status === 'added' ? emptyProject : source(repository, file.previous ?? file.path, base!, signal),
149
212
  status === 'removed' ? emptyProject : source(pr.head.repository ?? repository, file.path, pr.head.sha, signal)
150
213
  ]);
151
- files.push({path: file.path, previousPath: file.previous, patch: createProjectSourceDiff(before, after)});
214
+ const loadAsset = rawAssetLoader({
215
+ before: status === 'added' ? null : {repository, sha: base!, path: file.previous ?? file.path},
216
+ after: status === 'removed' ? null : {repository: pr.head.repository ?? repository, sha: pr.head.sha, path: file.path}
217
+ }, send, signal);
218
+ files.push({path: file.path, previousPath: file.previous, patch: createProjectSourceDiff(before, after), loadAsset});
152
219
  } catch (error) {
153
220
  if (signal.aborted) throw error;
154
221
  files.push({...related, unavailable: true});
package/src/index.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, {useEffect, useRef, useState} from 'react';
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
4
  import {openRepository} from './repository.js';
@@ -249,6 +249,9 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
249
249
  event.preventDefault(); last?.focus();
250
250
  } else if (!event.shiftKey && document.activeElement === last) {event.preventDefault(); first?.focus();}
251
251
  };
252
+ // One loader per selected change, so thumbnails read each asset once.
253
+ const loadAsset = useMemo(() => state.repository && state.snapshot && state.selected ?
254
+ state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
252
255
  if (!visible) return null;
253
256
  const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
254
257
  return <div className="twgit-backdrop">
@@ -314,7 +317,7 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
314
317
  </aside>
315
318
  <main className="twgit-diff">
316
319
  {state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
317
- renderBlock={host.renderReviewBlock}/> : <p>{labels.select}</p>}
320
+ renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
318
321
  </main>
319
322
  </div>}
320
323
  <footer>{labels.permissions}</footer>
@@ -0,0 +1,33 @@
1
+ // Shared by the local and GitHub asset loaders. Kept free of React so the repository entry stays UI-free.
2
+
3
+ /** The largest asset a thumbnail reads. */
4
+ export const assetLimit = 10 * 1024 * 1024;
5
+ /** The largest sb3-source.json read to find the assets directory. */
6
+ export const folderSettingsLimit = 1024 * 1024;
7
+
8
+ /** Asset names are plain `<md5>.<ext>` file names; anything else could address a path outside the assets directory. */
9
+ export const isAssetName = (name: unknown): name is string =>
10
+ typeof name === 'string' && /^[0-9A-Za-z_-]{1,128}\.[0-9A-Za-z]{1,8}$/.test(name);
11
+
12
+ /**
13
+ * The assets directory named by one version of a project folder's `sb3-source.json`, as path segments relative to the
14
+ * folder. `assets` when that file or its `assetsDirectory` field is missing or unreadable. `null` when the field names
15
+ * anything other than a plain relative path inside the folder.
16
+ */
17
+ export function assetsDirectoryParts(settings: string | null): string[] | null {
18
+ let value: unknown = 'assets';
19
+ if (settings !== null) {
20
+ try {
21
+ const parsed: unknown = JSON.parse(settings);
22
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) && 'assetsDirectory' in parsed) {
23
+ value = (parsed as {assetsDirectory: unknown}).assetsDirectory;
24
+ }
25
+ } catch {
26
+ // An unreadable file names no directory, as a missing one does.
27
+ }
28
+ }
29
+ if (typeof value !== 'string' || value.length > 1024) return null;
30
+ const parts = value.split('/');
31
+ return parts.every(part => part && part !== '.' && part !== '..' && part.toLowerCase() !== '.git' &&
32
+ !/[\\\0-\x1f\x7f]/.test(part)) ? parts : null;
33
+ }
@@ -0,0 +1,66 @@
1
+ import {useEffect, useState} from 'react';
2
+ import {isRecord} from './project-source-patch.js';
3
+ import type {ProjectTarget} from './project-source-patch.js';
4
+
5
+ export type ProjectVersion = 'before' | 'after';
6
+
7
+ /**
8
+ * Reads one asset of one version of a project file by its `md5ext` name (for example `0123…cdef.svg`), from the
9
+ * assets directory of that project. Resolves `null` when that version has no readable file of that name.
10
+ */
11
+ export type ProjectAssetLoader = (request: {version: ProjectVersion; md5ext: string; signal: AbortSignal}) => Promise<Blob | null>;
12
+
13
+ const imageTypes: Record<string, string> = {
14
+ svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp'
15
+ };
16
+
17
+ /** The costume a sprite shows, or the backdrop the stage shows, when the project data names an image file for it. */
18
+ export function currentCostume(target: ProjectTarget): {name: string; md5ext: string; type: string} | null {
19
+ const {costumes, currentCostume: index} = target as unknown as Record<string, unknown>;
20
+ if (!Array.isArray(costumes)) return null;
21
+ const costume: unknown = costumes[typeof index === 'number' && Number.isInteger(index) && index >= 0 ? index : 0];
22
+ if (!isRecord(costume)) return null;
23
+ const md5ext = typeof costume.md5ext === 'string' ? costume.md5ext :
24
+ typeof costume.assetId === 'string' && typeof costume.dataFormat === 'string' ? costume.assetId + '.' + costume.dataFormat : '';
25
+ // Asset names are plain file names; anything else could address a path outside the assets directory.
26
+ const match = /^[0-9A-Za-z_-]{1,128}\.([0-9A-Za-z]{1,8})$/.exec(md5ext);
27
+ const type = match ? imageTypes[match[1].toLowerCase()] : undefined;
28
+ if (!type) return null;
29
+ return {name: typeof costume.name === 'string' ? costume.name : '', md5ext, type};
30
+ }
31
+
32
+ // Thumbnails of one target appear in its heading and in each of its frames, so one read serves all of them.
33
+ const reads = new WeakMap<ProjectAssetLoader, Map<string, Promise<Blob | null>>>();
34
+ function readAsset(loadAsset: ProjectAssetLoader, version: ProjectVersion, md5ext: string) {
35
+ let cache = reads.get(loadAsset);
36
+ if (!cache) reads.set(loadAsset, cache = new Map());
37
+ const key = version + ':' + md5ext;
38
+ let read = cache.get(key);
39
+ if (!read) {
40
+ read = loadAsset({version, md5ext, signal: new AbortController().signal}).catch(() => null);
41
+ cache.set(key, read);
42
+ }
43
+ return read;
44
+ }
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 : '';
50
+ const [image, setImage] = useState<{key: string; url: string}>();
51
+ useEffect(() => {
52
+ if (!costume || !loadAsset) return;
53
+ let active = true;
54
+ let url: string | undefined;
55
+ void readAsset(loadAsset, version, costume.md5ext).then(blob => {
56
+ if (!blob || !active) return;
57
+ url = URL.createObjectURL(new Blob([blob], {type: costume.type}));
58
+ setImage({key, url});
59
+ });
60
+ return () => {
61
+ active = false;
62
+ if (url) URL.revokeObjectURL(url);
63
+ };
64
+ }, [key, loadAsset]);
65
+ return image?.key === key ? image.url : null;
66
+ }
@@ -22,8 +22,26 @@
22
22
  .twgit-block-diff-sides { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
23
23
  .twgit-block-diff-side { padding: 10px 14px; min-width: 0; }
24
24
  .twgit-block-diff-side + .twgit-block-diff-side { border-inline-start: 1px solid color-mix(in srgb, CanvasText 20%, Canvas); }
25
- .twgit-block-diff-preview { overflow: auto; max-height: 420px; }
26
- .twgit-block-diff-surface { position: relative; min-width: 100%; height: 64px; }
25
+ /* A preview is a small workspace: it scrolls and zooms inside its frame, which people can make taller. */
26
+ .twgit-block-diff-preview {
27
+ position: relative;
28
+ overflow: hidden;
29
+ resize: vertical;
30
+ min-height: 120px;
31
+ max-height: 85vh;
32
+ border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas);
33
+ border-radius: 6px;
34
+ }
35
+ .twgit-block-diff-surface { position: absolute; inset: 0; }
36
+ .twgit-block-diff-watermark { position: absolute; z-index: 2; top: 10px; right: 12px; pointer-events: none; }
37
+ .twgit-block-diff-watermark[data-navigation] { top: auto; right: var(--plus-watermark-right, 12px); bottom: var(--plus-watermark-bottom, 140px); }
38
+ .twgit-block-diff-watermark > img {
39
+ display: block;
40
+ max-width: var(--plus-watermark-size, 48px);
41
+ max-height: var(--plus-watermark-size, 48px);
42
+ opacity: .6;
43
+ }
44
+ .twgit-block-diff-link { display: block; padding: 6px 12px 0; }
27
45
  /* These markers also apply to the live Editor workspace returned by the host. */
28
46
  .twgit-changed-block > .blocklyPath { stroke: var(--twgit-change, #635bce); stroke-width: 2.5px; }
29
47
  .twgit-changed-block-part { outline: 2.5px solid #b4510a; outline-offset: 2px; }
@@ -77,3 +95,14 @@
77
95
  .twgit-pr-paging, .twgit-pr-revision { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
78
96
  .twgit-pr-revision code { overflow-wrap: anywhere; }
79
97
  .twgit-review .twgit-block-diff-side h4 { font-size: 1em; }
98
+
99
+ /* Costume (or backdrop) thumbnails in stage and sprite headings. */
100
+ .twgit-review-thumbnails { display: inline-flex; align-items: center; gap: 4px; margin-inline-end: 6px; vertical-align: middle; }
101
+ .twgit-review-thumbnails img {
102
+ width: 30px;
103
+ height: 30px;
104
+ object-fit: contain;
105
+ background: Canvas;
106
+ border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas));
107
+ border-radius: 4px;
108
+ }