@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.
- package/README.ja.md +22 -6
- package/README.md +9 -1
- package/dist/block-diff-preview.d.ts +18 -2
- package/dist/file-diff-view.d.ts +3 -1
- package/dist/index.js +11 -4
- package/dist/{libesm-OWRWzmu_.js → project-asset-paths-KV715791.js} +23 -1
- package/dist/project-asset-paths.d.ts +12 -0
- package/dist/project-assets.d.ts +19 -0
- package/dist/project-source-diff.d.ts +5 -1
- package/dist/project-source-diff.js +10 -7
- package/dist/project-source-review-model.d.ts +4 -1
- package/dist/project-source-review.d.ts +6 -2
- package/dist/{repository-DplMQiuo.js → repository-BQkADJmK.js} +71 -3
- package/dist/repository.d.ts +10 -0
- package/dist/repository.js +1 -1
- package/dist/{scratch-block-diff-renderer-CLYoOIB2.js → scratch-block-diff-renderer-xS8OYsRl.js} +453 -87
- package/dist/scratch-block-diff-renderer.d.ts +69 -6
- package/dist/style.css +31 -2
- package/package.json +7 -2
- package/src/block-diff-preview.tsx +89 -8
- package/src/file-diff-view.tsx +4 -3
- package/src/github-pull-requests.ts +73 -6
- package/src/index.tsx +5 -2
- package/src/project-asset-paths.ts +33 -0
- package/src/project-assets.ts +66 -0
- package/src/project-source-diff.css +31 -2
- package/src/project-source-diff.tsx +14 -7
- package/src/project-source-review-model.ts +4 -1
- package/src/project-source-review.tsx +56 -23
- package/src/repository.ts +58 -2
- package/src/scratch-block-diff-renderer.ts +129 -11
|
@@ -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
|
-
|
|
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
|
-
|
|
26
|
-
.twgit-block-diff-
|
|
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.
|
|
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
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
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>}
|
package/src/file-diff-view.tsx
CHANGED
|
@@ -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
|
|
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
|
|
36
|
+
if (done) return;
|
|
37
37
|
length += value.byteLength;
|
|
38
38
|
if (length > limit) throw invalidResponse();
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
.twgit-block-diff-
|
|
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
|
+
}
|