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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.ja.md +22 -6
  2. package/README.md +9 -1
  3. package/dist/block-diff-preview.d.ts +18 -2
  4. package/dist/file-diff-view.d.ts +3 -1
  5. package/dist/index.js +359 -15
  6. package/dist/{libesm-OWRWzmu_.js → project-asset-paths-KV715791.js} +23 -1
  7. package/dist/project-asset-paths.d.ts +12 -0
  8. package/dist/project-assets.d.ts +23 -0
  9. package/dist/project-change-list.d.ts +81 -0
  10. package/dist/project-change-model.d.ts +73 -0
  11. package/dist/project-source-diff.d.ts +7 -1
  12. package/dist/project-source-diff.js +10 -7
  13. package/dist/project-source-review-model.d.ts +4 -1
  14. package/dist/project-source-review.d.ts +11 -2
  15. package/dist/{repository-DplMQiuo.js → repository-VBp-UNWc.js} +134 -3
  16. package/dist/repository.d.ts +34 -0
  17. package/dist/repository.js +1 -1
  18. package/dist/{scratch-block-diff-renderer-CLYoOIB2.js → scratch-block-diff-renderer-DZR8ep-T.js} +708 -90
  19. package/dist/scratch-block-diff-renderer.d.ts +69 -6
  20. package/dist/style.css +48 -2
  21. package/package.json +7 -2
  22. package/src/block-diff-preview.tsx +89 -8
  23. package/src/file-diff-view.tsx +4 -3
  24. package/src/github-pull-requests.ts +73 -6
  25. package/src/index.tsx +42 -12
  26. package/src/project-asset-paths.ts +33 -0
  27. package/src/project-assets.ts +78 -0
  28. package/src/project-change-list.tsx +210 -0
  29. package/src/project-change-model.ts +212 -0
  30. package/src/project-source-diff.css +31 -2
  31. package/src/project-source-diff.tsx +17 -7
  32. package/src/project-source-review-model.ts +4 -1
  33. package/src/project-source-review.tsx +67 -27
  34. package/src/repository.ts +107 -2
  35. package/src/scratch-block-diff-renderer.ts +129 -11
  36. package/src/style.css +17 -0
@@ -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; }
@@ -106,4 +135,21 @@
106
135
  .twgit-view-switch { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 10px; }
107
136
  .twgit-review-unreadable { margin: 6px 0; padding: 8px; background: color-mix(in srgb, #d9822b 16%, Canvas); }
108
137
  .twgit-review-comparison .twgit-review-overview dl { margin: 4px 0 8px; }
138
+ .twgit-element-list > section { margin: 6px 0 10px; }
139
+ .twgit-element-target { display: flex; align-items: center; gap: 6px; margin: 10px 0 4px; font-size: 14px; }
140
+ .twgit-element-target img, .twgit-element img, .twgit-element-thumbnail { width: 28px; height: 28px; object-fit: contain; flex: none; }
141
+ .twgit-element-thumbnail { display: inline-block; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
142
+ .twgit-panel .twgit-element { display: flex; align-items: center; gap: 6px; padding-inline-start: 10px; }
143
+ .twgit-element-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
144
+ .twgit-file-list { margin-top: 10px; }
145
+ .twgit-element-pictures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
146
+ .twgit-element-picture { margin: 0; padding: 10px; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 6px; }
147
+ .twgit-element-picture img { display: block; max-width: 100%; max-height: 260px; margin: 8px auto 0; object-fit: contain;
148
+ background: repeating-conic-gradient(color-mix(in srgb, CanvasText 6%, Canvas) 0 25%, Canvas 0 50%) 0 0 / 16px 16px; }
149
+ .twgit-element-values { border-collapse: collapse; width: 100%; font-size: 13px; }
150
+ .twgit-element-values th, .twgit-element-values td { text-align: start; vertical-align: top; padding: 6px 8px;
151
+ border-bottom: 1px solid color-mix(in srgb, CanvasText 15%, Canvas); overflow-wrap: anywhere; }
152
+ .twgit-element-values td { font-family: ui-monospace, monospace; }
153
+ .twgit-element-files { margin-top: 12px; }
154
+ .twgit-panel .twgit-element span.twgit-element-name { width: auto; font-family: inherit; }
109
155
  /*$vite$:1*/
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.9.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,6 +1,8 @@
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
+ 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) => {
@@ -249,8 +265,13 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
249
265
  event.preventDefault(); last?.focus();
250
266
  } else if (!event.shiftKey && document.activeElement === last) {event.preventDefault(); first?.focus();}
251
267
  };
268
+ // One loader per selected change, so thumbnails read each asset once.
269
+ const loadAsset = useMemo(() => state.repository && state.snapshot && state.selected ?
270
+ state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
252
271
  if (!visible) return null;
253
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;
254
275
  return <div className="twgit-backdrop">
255
276
  <div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
256
277
  onKeyDown={focusTrap}>
@@ -294,15 +315,22 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
294
315
  onClick={() => void browse([state.browser!.path, name].filter(Boolean).join('/'))}>{name}/</button></li>)}</ul>
295
316
  </details>}
296
317
  <h3><button disabled={state.busy} onClick={() => void controller.refresh()}>{labels.worktree}</button></h3>
297
- {groups.map(group => <section key={group}>
298
- <h3>{labels[group]} · {state.changes.filter(change => change.group === group).length}</h3>
299
- <ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}>
300
- <button className="twgit-file" data-git-group={group} disabled={state.busy}
301
- aria-pressed={state.selected === change} onClick={() => void selectFile(change)}>
302
- <span>{!change.after ? 'D' : !change.before ? 'A' : 'M'}</span> {change.path}
303
- </button>
304
- </li>)}</ul>
305
- </section>)}
318
+ {projectChanges === 'loading' && <p role="status">{projectChangeLabels(locale).loading}</p>}
319
+ {projectChanges === 'unreadable' && <p>{projectChangeLabels(locale).unreadable}</p>}
320
+ {(() => {
321
+ const fileList = groups.map(group => <section key={group}>
322
+ <h3>{labels[group]} · {state.changes.filter(change => change.group === group).length}</h3>
323
+ <ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}>
324
+ <button className="twgit-file" data-git-group={group} disabled={state.busy}
325
+ aria-pressed={state.selected === change} onClick={() => openFile(change)}>
326
+ <span>{!change.after ? 'D' : !change.before ? 'A' : 'M'}</span> {change.path}
327
+ </button>
328
+ </li>)}</ul>
329
+ </section>);
330
+ return projectList ? <ProjectChangeListView changes={projectList} selected={selection ? element : null}
331
+ locale={locale} busy={state.busy} onSelect={openElement} onSelectFile={openFile} fileList={fileList}
332
+ fileListOpen={fileListOpen} setFileListOpen={setFileListOpen}/> : fileList;
333
+ })()}
306
334
  {!state.changes.length && <p>{labels.clean}</p>}
307
335
  <details><summary>{labels.branches}</summary><ul>{state.snapshot?.branches.map(branch => <li key={branch}>{branch}</li>)}</ul></details>
308
336
  <h3>{labels.history}</h3>
@@ -313,8 +341,10 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
313
341
  </li>)}</ol>
314
342
  </aside>
315
343
  <main className="twgit-diff">
316
- {state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
317
- renderBlock={host.renderReviewBlock}/> : <p>{labels.select}</p>}
344
+ {selection && projectList ? <ProjectElementView changes={projectList} selection={selection} locale={locale}
345
+ renderBlock={host.renderReviewBlock} onSelectFile={openFile}/> :
346
+ state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
347
+ renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
318
348
  </main>
319
349
  </div>}
320
350
  <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
+ }