@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.
- 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 +359 -15
- 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 +23 -0
- package/dist/project-change-list.d.ts +81 -0
- package/dist/project-change-model.d.ts +73 -0
- package/dist/project-source-diff.d.ts +7 -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 +11 -2
- package/dist/{repository-DplMQiuo.js → repository-VBp-UNWc.js} +134 -3
- package/dist/repository.d.ts +34 -0
- package/dist/repository.js +1 -1
- package/dist/{scratch-block-diff-renderer-CLYoOIB2.js → scratch-block-diff-renderer-DZR8ep-T.js} +708 -90
- package/dist/scratch-block-diff-renderer.d.ts +69 -6
- package/dist/style.css +48 -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 +42 -12
- package/src/project-asset-paths.ts +33 -0
- package/src/project-assets.ts +78 -0
- package/src/project-change-list.tsx +210 -0
- package/src/project-change-model.ts +212 -0
- package/src/project-source-diff.css +31 -2
- package/src/project-source-diff.tsx +17 -7
- package/src/project-source-review-model.ts +4 -1
- package/src/project-source-review.tsx +67 -27
- package/src/repository.ts +107 -2
- package/src/scratch-block-diff-renderer.ts +129 -11
- 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
|
-
|
|
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; }
|
|
@@ -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.
|
|
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
|
-
|
|
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,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
|
-
{
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
{
|
|
317
|
-
renderBlock={host.renderReviewBlock}/> :
|
|
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
|
+
}
|