@kubohiroya/turbowarp-plus-editor-git 0.0.0-stage → 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/LICENSE +373 -0
- package/README.ja.md +121 -0
- package/README.md +43 -2
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/dist/THIRD_PARTY_NOTICES.md +1186 -0
- package/dist/block-diff-location.d.ts +22 -0
- package/dist/block-diff-preview.d.ts +33 -0
- package/dist/browser.d.ts +4 -0
- package/dist/connection-settings.d.ts +35 -0
- package/dist/file-diff-view.d.ts +18 -0
- package/dist/filesystem.d.ts +89 -0
- package/dist/github-pull-requests.d.ts +38 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +3563 -0
- package/dist/messages.d.ts +112 -0
- package/dist/mutation-fs.d.ts +58 -0
- package/dist/objectSpread2-Dkd68nJH.js +101 -0
- package/dist/project-asset-paths-KV715791.js +761 -0
- package/dist/project-asset-paths.d.ts +12 -0
- package/dist/project-assets.d.ts +19 -0
- package/dist/project-source-diff.d.ts +26 -0
- package/dist/project-source-diff.js +172 -0
- package/dist/project-source-patch.d.ts +39 -0
- package/dist/project-source-review-model.d.ts +23 -0
- package/dist/project-source-review.d.ts +38 -0
- package/dist/pull-request-panel.d.ts +8 -0
- package/dist/remote-operations.d.ts +5 -0
- package/dist/repository-BQkADJmK.js +17280 -0
- package/dist/repository.d.ts +71 -0
- package/dist/repository.js +1 -0
- package/dist/scratch-block-diff-renderer-xS8OYsRl.js +1830 -0
- package/dist/scratch-block-diff-renderer.d.ts +128 -0
- package/dist/smart-http-BmHEkE7K.js +303 -0
- package/dist/smart-http.d.ts +60 -0
- package/dist/smart-http.js +1 -0
- package/dist/style.css +138 -0
- package/package.json +65 -4
- package/src/block-diff-location.ts +63 -0
- package/src/block-diff-preview.tsx +117 -0
- package/src/browser.ts +5 -0
- package/src/connection-settings.tsx +103 -0
- package/src/file-diff-view.tsx +44 -0
- package/src/filesystem.ts +125 -0
- package/src/github-pull-requests.ts +229 -0
- package/src/index.tsx +352 -0
- package/src/messages.ts +64 -0
- package/src/mutation-fs.ts +204 -0
- package/src/project-asset-paths.ts +33 -0
- package/src/project-assets.ts +66 -0
- package/src/project-source-diff.css +108 -0
- package/src/project-source-diff.tsx +139 -0
- package/src/project-source-patch.ts +126 -0
- package/src/project-source-review-model.ts +116 -0
- package/src/project-source-review.tsx +250 -0
- package/src/pull-request-panel.tsx +134 -0
- package/src/remote-operations.ts +90 -0
- package/src/repository.ts +364 -0
- package/src/scratch-block-diff-renderer.ts +331 -0
- package/src/smart-http.ts +190 -0
- package/src/style.css +29 -0
- package/src/styles.d.ts +1 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +26 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import {isRecord} from './project-source-patch.js';
|
|
2
|
+
import type {BlockChangeKind, BlockPart, BlockSide, BlockTable} from './project-source-patch.js';
|
|
3
|
+
import {markBlockLocation} from './block-diff-location.js';
|
|
4
|
+
import type {DiffSvgBlock} from './block-diff-location.js';
|
|
5
|
+
|
|
6
|
+
interface BlockWorkspace {
|
|
7
|
+
dispose(): void;
|
|
8
|
+
markFocused(): void;
|
|
9
|
+
getBlocksBoundingBox(): {x?: number; y?: number; width: number; height: number};
|
|
10
|
+
getBlockById(id: string): DiffSvgBlock | null;
|
|
11
|
+
options?: {pathToMedia?: string; zoomOptions?: {startScale?: number}};
|
|
12
|
+
scale?: number;
|
|
13
|
+
scrollX?: number;
|
|
14
|
+
scrollY?: number;
|
|
15
|
+
translate?(x: number, y: number): void;
|
|
16
|
+
getMetrics?(): {absoluteLeft?: number; absoluteTop?: number} | null;
|
|
17
|
+
scrollbar?: {resize(): void} | null;
|
|
18
|
+
}
|
|
19
|
+
interface InjectOptions {
|
|
20
|
+
readOnly: true;
|
|
21
|
+
sounds: false;
|
|
22
|
+
media?: string;
|
|
23
|
+
scrollbars?: boolean;
|
|
24
|
+
zoom?: {controls: boolean; wheel: boolean; startScale: number; maxScale: number; minScale: number; scaleSpeed: number};
|
|
25
|
+
}
|
|
26
|
+
export interface ScratchBlocksPort {
|
|
27
|
+
inject(container: HTMLElement, options: InjectOptions): BlockWorkspace;
|
|
28
|
+
svgResize(workspace: BlockWorkspace): void;
|
|
29
|
+
getMainWorkspace(): BlockWorkspace | null;
|
|
30
|
+
mainWorkspace: BlockWorkspace | null;
|
|
31
|
+
Events: {disable(): void; enable(): void};
|
|
32
|
+
Blocks: Record<string, unknown>;
|
|
33
|
+
defineBlocksWithJsonArray?(definitions: Record<string, unknown>[]): void;
|
|
34
|
+
OUTPUT_SHAPE_ROUND?: number;
|
|
35
|
+
Xml: {textToDom(xml: string): Element; domToWorkspace(xml: Element, workspace: BlockWorkspace): unknown};
|
|
36
|
+
}
|
|
37
|
+
export interface ScratchBlockDiffRendererOptions {
|
|
38
|
+
getBlockly(): ScratchBlocksPort | Promise<ScratchBlocksPort>;
|
|
39
|
+
showUnregisteredBlocks?: boolean;
|
|
40
|
+
locale?: string;
|
|
41
|
+
deserializeBlocks(blocks: BlockTable): Record<string, {id: string; topLevel?: boolean}>;
|
|
42
|
+
createBlockContainer(): {
|
|
43
|
+
createBlock(block: {id: string}, deferProjectChanged: true): void;
|
|
44
|
+
blockToXML(id: string): string | undefined;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Adds the host's workspace navigation to each preview, as in its own code area: for example the costume
|
|
48
|
+
* thumbnail, a minimap and a row of zoom buttons in the lower right. `watermark` holds the costume thumbnail when
|
|
49
|
+
* the version has one; the host positions it. Without this option, previews keep Scratch Blocks' zoom buttons.
|
|
50
|
+
*/
|
|
51
|
+
attachNavigation?(workspace: unknown, blockly: ScratchBlocksPort, watermark?: HTMLElement): {dispose(): void} | null | undefined;
|
|
52
|
+
}
|
|
53
|
+
export interface ScriptBlockMark {
|
|
54
|
+
id: string;
|
|
55
|
+
kind: BlockChangeKind;
|
|
56
|
+
parts: readonly BlockPart[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Position and zoom of one preview, in Scratch Blocks workspace terms. */
|
|
60
|
+
export interface BlockPreviewViewport {scale: number; x: number; y: number}
|
|
61
|
+
/** Lets several previews of one comparison share their zoom, scroll position and height. */
|
|
62
|
+
export interface BlockPreviewView {
|
|
63
|
+
/** The zoom and frame height at which this preview shows all of its blocks. */
|
|
64
|
+
readonly fit: {scale: number; height: number};
|
|
65
|
+
/** Applies a zoom and frame height, scrolling to the start of the blocks. */
|
|
66
|
+
layout(fit: {scale: number; height: number}): void;
|
|
67
|
+
viewport(): BlockPreviewViewport;
|
|
68
|
+
setViewport(viewport: BlockPreviewViewport): void;
|
|
69
|
+
/** Calls `listener` after the user scrolls or zooms this preview. */
|
|
70
|
+
onChange(listener: () => void): () => void;
|
|
71
|
+
}
|
|
72
|
+
export type ProjectBlockRendering = (() => void) | {dispose(): void; view?: BlockPreviewView} | undefined;
|
|
73
|
+
export type ProjectBlockRenderer = (args: BlockSide & {
|
|
74
|
+
container: HTMLElement; withContext: boolean; parts?: readonly BlockPart[]; marks?: readonly ScriptBlockMark[]; signal: AbortSignal;
|
|
75
|
+
/** The costume thumbnail element of this version, placed in the preview's lower right by the host navigation. */
|
|
76
|
+
watermark?: HTMLElement;
|
|
77
|
+
}) => Promise<ProjectBlockRendering>;
|
|
78
|
+
|
|
79
|
+
function scriptRoot({target, id}: BlockSide): string {
|
|
80
|
+
const visited = new Set<string>();
|
|
81
|
+
for (;;) {
|
|
82
|
+
const block = target.blocks[id];
|
|
83
|
+
if (!isRecord(block) || typeof block.parent !== 'string' || !Object.hasOwn(target.blocks, block.parent)) return id;
|
|
84
|
+
if (visited.has(id)) throw new Error('Cyclic block parents.');
|
|
85
|
+
visited.add(id);
|
|
86
|
+
id = block.parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function safeMutation(value: unknown, root: boolean, depth = 0): boolean {
|
|
91
|
+
if (!isRecord(value) || depth > 32 || value.tagName !== (root ? 'mutation' : 'arg') || !Array.isArray(value.children)) return false;
|
|
92
|
+
return Object.entries(value).every(([key, item]) => key === 'children' ||
|
|
93
|
+
(/^[a-zA-Z][\w-]*$/.test(key) && ['string', 'number', 'boolean'].includes(typeof item))) &&
|
|
94
|
+
value.children.every(child => safeMutation(child, false, depth + 1));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function selectBlocks({target, id}: BlockSide, root: string, withContext: boolean): BlockTable {
|
|
98
|
+
const selected: BlockTable = Object.create(null);
|
|
99
|
+
const visiting = new Set<string>();
|
|
100
|
+
let blockCount = 0;
|
|
101
|
+
function visit(blockId: string, followNext: boolean, depth: number) {
|
|
102
|
+
if (visiting.has(blockId) || depth > 128) throw new Error('Block preview exceeds its graph limit.');
|
|
103
|
+
if (Object.hasOwn(selected, blockId) || !Object.hasOwn(target.blocks, blockId)) return;
|
|
104
|
+
if (++blockCount > 800) throw new Error('Block preview exceeds its graph limit.');
|
|
105
|
+
if (blockId === '__proto__') throw new Error('Invalid block ID.');
|
|
106
|
+
const data = target.blocks[blockId];
|
|
107
|
+
if (Array.isArray(data)) {
|
|
108
|
+
selected[blockId] = data.length > 3 ? [...data.slice(0, 3), 20, 20] : [...data];
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!isRecord(data) || typeof data.opcode !== 'string') throw new Error('Invalid Scratch block.');
|
|
112
|
+
const block = structuredClone(data);
|
|
113
|
+
if (block.mutation && !safeMutation(block.mutation, true)) throw new Error('Unsupported block mutation.');
|
|
114
|
+
block.x = 20;
|
|
115
|
+
block.y = 20;
|
|
116
|
+
block.topLevel = blockId === root;
|
|
117
|
+
if (blockId === root) block.shadow = false;
|
|
118
|
+
delete block.comment;
|
|
119
|
+
if (!followNext) block.next = null;
|
|
120
|
+
selected[blockId] = block;
|
|
121
|
+
visiting.add(blockId);
|
|
122
|
+
if (isRecord(block.inputs)) {
|
|
123
|
+
for (const [name, input] of Object.entries(block.inputs)) {
|
|
124
|
+
if (!withContext && /^SUBSTACK\d*$/.test(name)) {
|
|
125
|
+
delete block.inputs[name];
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!Array.isArray(input)) continue;
|
|
129
|
+
for (const child of input.slice(1, 3)) {
|
|
130
|
+
if (typeof child === 'string') visit(child, withContext, depth + 1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (typeof block.next === 'string') visit(block.next, true, depth + 1);
|
|
135
|
+
visiting.delete(blockId);
|
|
136
|
+
}
|
|
137
|
+
visit(root, withContext, 0);
|
|
138
|
+
if (!Object.hasOwn(selected, id)) throw new Error('The changed block is not reachable.');
|
|
139
|
+
return selected;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const placeholderPrefix = 'twgit_review_placeholder_';
|
|
143
|
+
let placeholderSerial = 0;
|
|
144
|
+
|
|
145
|
+
function statementInput(element: Element) {
|
|
146
|
+
return element.localName === 'statement' || /^SUBSTACK\d*$/.test(element.getAttribute('name') ?? '');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function placeholderDefinition(element: Element, opcode: string, type: string, blockly: ScratchBlocksPort, locale: string) {
|
|
150
|
+
const text = (locale.startsWith('ja') ? '未登録: ' : 'Unregistered: ') + opcode.slice(0, 120);
|
|
151
|
+
const args: Record<string, unknown>[] = [{type: 'field_label', text}];
|
|
152
|
+
const branches: string[] = [];
|
|
153
|
+
for (const child of Array.from(element.children)) {
|
|
154
|
+
const name = child.getAttribute('name');
|
|
155
|
+
if (!name) continue;
|
|
156
|
+
if (child.localName === 'field') args.push({type: 'field_input', name, text: ''});
|
|
157
|
+
else if (statementInput(child)) branches.push(name);
|
|
158
|
+
else if (child.localName === 'value') args.push({type: 'input_value', name});
|
|
159
|
+
}
|
|
160
|
+
const parent = element.parentElement;
|
|
161
|
+
const reporter = parent?.localName === 'value' && !statementInput(parent);
|
|
162
|
+
const definition: Record<string, unknown> = {
|
|
163
|
+
type, message0: args.map((_, index) => '%' + (index + 1)).join(' '), args0: args, inputsInline: true,
|
|
164
|
+
colour: '#687488', colourSecondary: '#566274', colourTertiary: '#4b5564',
|
|
165
|
+
...(reporter ? {output: null, outputShape: blockly.OUTPUT_SHAPE_ROUND} : {previousStatement: null, nextStatement: null})
|
|
166
|
+
};
|
|
167
|
+
for (const [index, name] of branches.entries()) {
|
|
168
|
+
definition['message' + (index + 1)] = '%1';
|
|
169
|
+
definition['args' + (index + 1)] = [{type: 'input_statement', name}];
|
|
170
|
+
}
|
|
171
|
+
return definition;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Scratch Blocks' scale for the code area, which keeps the blocks the size people edit them at.
|
|
175
|
+
const defaultScale = 0.675;
|
|
176
|
+
const padding = 16;
|
|
177
|
+
const minHeight = 160;
|
|
178
|
+
const maxHeight = 420;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Sizes a preview frame to its blocks and reports scrolling and zooming, so both versions can move together.
|
|
182
|
+
* The frame keeps a gutter on the right for the zoom buttons, and for the minimap and thumbnail when present.
|
|
183
|
+
*/
|
|
184
|
+
function createView(workspace: BlockWorkspace, blockly: ScratchBlocksPort, surface: HTMLElement, frame: HTMLElement,
|
|
185
|
+
startScale: number, navigation: boolean) {
|
|
186
|
+
const bounds = workspace.getBlocksBoundingBox();
|
|
187
|
+
const left = bounds.x ?? 0, top = bounds.y ?? 0;
|
|
188
|
+
const gutter = navigation ? 150 : 56;
|
|
189
|
+
const width = frame.clientWidth || 400;
|
|
190
|
+
const scale = Math.max(0.25, Math.min(startScale, (width - padding - gutter) / Math.max(1, bounds.width),
|
|
191
|
+
(maxHeight - 2 * padding) / Math.max(1, bounds.height)));
|
|
192
|
+
const fit = {scale, height: Math.min(maxHeight, Math.max(minHeight, Math.ceil(bounds.height * scale + 2 * padding)))};
|
|
193
|
+
const listeners = new Set<() => void>();
|
|
194
|
+
const translate = workspace.translate?.bind(workspace);
|
|
195
|
+
let quiet = false;
|
|
196
|
+
// Scrolling, dragging and every zoom end in translate, which moves the canvas.
|
|
197
|
+
if (translate) workspace.translate = (x, y) => {
|
|
198
|
+
translate(x, y);
|
|
199
|
+
if (!quiet) for (const listener of listeners) listener();
|
|
200
|
+
};
|
|
201
|
+
const setViewport = ({scale, x, y}: BlockPreviewViewport) => {
|
|
202
|
+
if (!translate) return;
|
|
203
|
+
quiet = true;
|
|
204
|
+
try {
|
|
205
|
+
// setScale would also close menus in the Editor's own workspace.
|
|
206
|
+
workspace.scale = scale;
|
|
207
|
+
workspace.scrollX = x;
|
|
208
|
+
workspace.scrollY = y;
|
|
209
|
+
const metrics = workspace.getMetrics?.();
|
|
210
|
+
translate(x + (metrics?.absoluteLeft ?? 0), y + (metrics?.absoluteTop ?? 0));
|
|
211
|
+
workspace.scrollbar?.resize();
|
|
212
|
+
} finally {
|
|
213
|
+
quiet = false;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const layout = ({scale, height}: {scale: number; height: number}) => {
|
|
217
|
+
// The frame takes the height, so people can still drag its resize handle afterwards.
|
|
218
|
+
frame.style.height = height + 'px';
|
|
219
|
+
blockly.svgResize(workspace);
|
|
220
|
+
setViewport({scale, x: padding - left * scale, y: padding - top * scale});
|
|
221
|
+
};
|
|
222
|
+
layout(fit);
|
|
223
|
+
// Keep the drawing area matched to the frame when the panel or the frame is resized.
|
|
224
|
+
const resize = new (surface.ownerDocument.defaultView ?? globalThis).ResizeObserver(() => blockly.svgResize(workspace));
|
|
225
|
+
resize.observe(surface);
|
|
226
|
+
// A plain wheel scrolls the review; Ctrl and trackpad pinches still zoom the preview.
|
|
227
|
+
const wheel = (event: WheelEvent) => {if (!event.ctrlKey) event.stopPropagation();};
|
|
228
|
+
surface.addEventListener('wheel', wheel, {capture: true});
|
|
229
|
+
const api: BlockPreviewView = {
|
|
230
|
+
fit, layout, setViewport,
|
|
231
|
+
viewport: () => ({scale: workspace.scale ?? fit.scale, x: workspace.scrollX ?? 0, y: workspace.scrollY ?? 0}),
|
|
232
|
+
onChange(listener) {
|
|
233
|
+
listeners.add(listener);
|
|
234
|
+
return () => listeners.delete(listener);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
return {api, dispose() {
|
|
238
|
+
frame.style.removeProperty('height');
|
|
239
|
+
listeners.clear();
|
|
240
|
+
resize.disconnect();
|
|
241
|
+
surface.removeEventListener('wheel', wheel, {capture: true});
|
|
242
|
+
if (translate) workspace.translate = translate;
|
|
243
|
+
}};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Use the host's Scratch VM serializer and Scratch Blocks definitions in an isolated workspace. */
|
|
247
|
+
export function createScratchBlocksDiffRenderer(options: ScratchBlockDiffRendererOptions): ProjectBlockRenderer {
|
|
248
|
+
return async ({container, target, id, withContext, parts = [], marks, signal, watermark}) => {
|
|
249
|
+
const blockly = await options.getBlockly();
|
|
250
|
+
if (signal.aborted) return;
|
|
251
|
+
const root = withContext ? scriptRoot({target, id}) : id;
|
|
252
|
+
const selected = selectBlocks({target, id}, root, withContext);
|
|
253
|
+
const compactRoot = Array.isArray(selected[root]);
|
|
254
|
+
const blocks = options.deserializeBlocks(selected);
|
|
255
|
+
const serializedRoot = compactRoot ? Object.values(blocks).find(block => block.topLevel)?.id : root;
|
|
256
|
+
if (!serializedRoot) throw new Error('Missing Scratch block.');
|
|
257
|
+
const vmBlocks = options.createBlockContainer();
|
|
258
|
+
for (const block of Object.values(blocks)) vmBlocks.createBlock(block, true);
|
|
259
|
+
const xml = vmBlocks.blockToXML(serializedRoot);
|
|
260
|
+
if (!xml) throw new Error('Missing Scratch block XML.');
|
|
261
|
+
const dom = blockly.Xml.textToDom('<xml>' + xml + '</xml>');
|
|
262
|
+
const main = blockly.getMainWorkspace();
|
|
263
|
+
const surface = container.ownerDocument.createElement('div');
|
|
264
|
+
surface.className = 'twgit-block-diff-surface';
|
|
265
|
+
container.append(surface);
|
|
266
|
+
let workspace: BlockWorkspace | undefined;
|
|
267
|
+
let view!: ReturnType<typeof createView>;
|
|
268
|
+
let navigation: {dispose(): void} | null | undefined;
|
|
269
|
+
const cleanups: (() => void)[] = [];
|
|
270
|
+
const placeholders: string[] = [];
|
|
271
|
+
const dispose = () => {
|
|
272
|
+
const active = blockly.getMainWorkspace();
|
|
273
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
274
|
+
blockly.Events.disable();
|
|
275
|
+
try {
|
|
276
|
+
navigation?.dispose();
|
|
277
|
+
workspace?.dispose();
|
|
278
|
+
} finally {
|
|
279
|
+
if (active === workspace) blockly.mainWorkspace = main;
|
|
280
|
+
for (const type of placeholders) delete blockly.Blocks[type];
|
|
281
|
+
blockly.Events.enable();
|
|
282
|
+
surface.remove();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
blockly.Events.disable();
|
|
286
|
+
try {
|
|
287
|
+
for (const element of Array.from(dom.querySelectorAll('block,shadow'))) {
|
|
288
|
+
const opcode = element.getAttribute('type') ?? '';
|
|
289
|
+
if (!opcode.startsWith(placeholderPrefix) && Object.hasOwn(blockly.Blocks, opcode)) continue;
|
|
290
|
+
if (!options.showUnregisteredBlocks || !blockly.defineBlocksWithJsonArray) throw new Error('Unregistered Scratch block.');
|
|
291
|
+
let alias: string;
|
|
292
|
+
do {alias = placeholderPrefix + ++placeholderSerial;} while (Object.hasOwn(blockly.Blocks, alias));
|
|
293
|
+
placeholders.push(alias);
|
|
294
|
+
blockly.defineBlocksWithJsonArray([placeholderDefinition(element, opcode, alias, blockly,
|
|
295
|
+
options.locale ?? container.ownerDocument.documentElement.lang)]);
|
|
296
|
+
element.setAttribute('type', alias);
|
|
297
|
+
}
|
|
298
|
+
const startScale = main?.options?.zoomOptions?.startScale ?? defaultScale;
|
|
299
|
+
workspace = blockly.inject(surface, {readOnly: true, sounds: false, media: main?.options?.pathToMedia, scrollbars: true,
|
|
300
|
+
zoom: {controls: true, wheel: true, startScale, maxScale: 4, minScale: 0.25, scaleSpeed: 1.2}});
|
|
301
|
+
workspace.markFocused = () => {};
|
|
302
|
+
blockly.Xml.domToWorkspace(dom, workspace);
|
|
303
|
+
if (marks) {
|
|
304
|
+
for (const mark of marks) {
|
|
305
|
+
const changed = workspace.getBlockById(mark.id === root ? serializedRoot : mark.id);
|
|
306
|
+
if (!changed) continue;
|
|
307
|
+
changed.getSvgRoot()?.classList.add('twgit-block-diff-' + mark.kind);
|
|
308
|
+
markBlockLocation(changed, mark.parts);
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
const changed = workspace.getBlockById(id === root ? serializedRoot : id);
|
|
312
|
+
if (changed) markBlockLocation(changed, parts);
|
|
313
|
+
}
|
|
314
|
+
view = createView(workspace, blockly, surface, container, startScale, !!options.attachNavigation);
|
|
315
|
+
cleanups.push(view.dispose);
|
|
316
|
+
navigation = options.attachNavigation?.(workspace, blockly, watermark);
|
|
317
|
+
if (navigation && watermark) {
|
|
318
|
+
// The host now positions the thumbnail beside its minimap.
|
|
319
|
+
watermark.dataset.navigation = '';
|
|
320
|
+
cleanups.push(() => delete watermark.dataset.navigation);
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
dispose();
|
|
324
|
+
throw error;
|
|
325
|
+
} finally {
|
|
326
|
+
blockly.mainWorkspace = main;
|
|
327
|
+
blockly.Events.enable();
|
|
328
|
+
}
|
|
329
|
+
return {dispose, view: view.api};
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type {HttpClient} from 'isomorphic-git';
|
|
2
|
+
|
|
3
|
+
export const MAX_GIT_REQUEST_BYTES = 32 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
type Connection = Readonly<{mode: 'desktop' | 'cloudflare'; proxyOrigin: string}>;
|
|
6
|
+
type Session = Readonly<{connection: Connection; bearer: string; expiresAt: number}>;
|
|
7
|
+
|
|
8
|
+
export interface GitSmartHttpOptions {
|
|
9
|
+
readonly remote: string;
|
|
10
|
+
readonly connection: Connection;
|
|
11
|
+
readonly getSession: () => Promise<Session | null>;
|
|
12
|
+
readonly maxRequestBytes?: number;
|
|
13
|
+
readonly signal?: AbortSignal;
|
|
14
|
+
readonly fetch?: typeof fetch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Request = {
|
|
18
|
+
url: string;
|
|
19
|
+
method?: string;
|
|
20
|
+
body?: AsyncIterable<Uint8Array>;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const proxyCodes = {
|
|
25
|
+
400: ['BAD_REQUEST'],
|
|
26
|
+
401: ['SESSION_REQUIRED', 'SESSION_EXPIRED', 'REAUTH_REQUIRED'],
|
|
27
|
+
403: ['FORBIDDEN'],
|
|
28
|
+
409: ['REDIRECT_REJECTED'],
|
|
29
|
+
411: ['LENGTH_REQUIRED'],
|
|
30
|
+
413: ['PAYLOAD_TOO_LARGE'],
|
|
31
|
+
429: ['RATE_LIMITED'],
|
|
32
|
+
502: ['UPSTREAM_FAILURE'],
|
|
33
|
+
504: ['UPSTREAM_TIMEOUT']
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
type ProxyErrorCode = (typeof proxyCodes)[keyof typeof proxyCodes][number];
|
|
37
|
+
export type GitSmartHttpErrorCode = ProxyErrorCode | 'INVALID_REMOTE' | 'INVALID_REQUEST' |
|
|
38
|
+
'INVALID_CONNECTION' | 'INVALID_SESSION' | 'SESSION_UNAVAILABLE' | 'ABORTED' | 'NETWORK_ERROR' |
|
|
39
|
+
'INVALID_RESPONSE' | 'PUSH_OUTCOME_UNKNOWN';
|
|
40
|
+
|
|
41
|
+
export class GitSmartHttpError extends Error {
|
|
42
|
+
constructor(readonly code: GitSmartHttpErrorCode, readonly statusCode?: number, readonly requestId?: string) {
|
|
43
|
+
super(code);
|
|
44
|
+
this.name = 'GitSmartHttpError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function remoteUrl(remote: string) {
|
|
49
|
+
// Validate the raw URL before a URL parser can discard dot segments, whitespace, or a default port.
|
|
50
|
+
const match = /^https:\/\/github\.com\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/u.exec(remote);
|
|
51
|
+
if (!match || match[0] !== remote || ['.', '..', '.git'].includes(match[2].toLowerCase())) {
|
|
52
|
+
throw new GitSmartHttpError('INVALID_REMOTE');
|
|
53
|
+
}
|
|
54
|
+
return `https://github.com/${match[1]}/${match[2]}.git`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function checkConnection({mode, proxyOrigin}: Connection) {
|
|
58
|
+
const url = URL.parse(proxyOrigin);
|
|
59
|
+
if (!url || url.origin !== proxyOrigin ||
|
|
60
|
+
(mode === 'desktop' ? !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/u.test(proxyOrigin) :
|
|
61
|
+
mode !== 'cloudflare' || url.protocol !== 'https:')) {
|
|
62
|
+
throw new GitSmartHttpError('INVALID_CONNECTION');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkAbort(signal?: AbortSignal) {
|
|
67
|
+
if (signal?.aborted) throw new GitSmartHttpError('ABORTED');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function boundedBody(source: AsyncIterable<Uint8Array>, limit: number, signal?: AbortSignal) {
|
|
71
|
+
const parts: Uint8Array<ArrayBuffer>[] = [];
|
|
72
|
+
let size = 0;
|
|
73
|
+
for await (const bytes of source) {
|
|
74
|
+
checkAbort(signal);
|
|
75
|
+
size += bytes.byteLength;
|
|
76
|
+
if (size > limit) throw new GitSmartHttpError('PAYLOAD_TOO_LARGE');
|
|
77
|
+
parts.push(bytes.slice());
|
|
78
|
+
}
|
|
79
|
+
return new Blob(parts);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function* responseBytes(body: ReadableStream<Uint8Array>, code: GitSmartHttpErrorCode, signal?: AbortSignal) {
|
|
83
|
+
const reader = body.getReader();
|
|
84
|
+
try {
|
|
85
|
+
for (;;) {
|
|
86
|
+
const {done, value} = await reader.read();
|
|
87
|
+
if (done) return;
|
|
88
|
+
yield value;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
if (code !== 'PUSH_OUTCOME_UNKNOWN') checkAbort(signal);
|
|
92
|
+
throw new GitSmartHttpError(code);
|
|
93
|
+
} finally {
|
|
94
|
+
await reader.cancel().catch(() => {});
|
|
95
|
+
reader.releaseLock();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function proxyError(response: Response, writing: boolean) {
|
|
100
|
+
const status = response.status;
|
|
101
|
+
const allowed: readonly ProxyErrorCode[] | undefined = proxyCodes[status as keyof typeof proxyCodes];
|
|
102
|
+
let code: GitSmartHttpErrorCode = allowed?.[0] ?? 'INVALID_RESPONSE';
|
|
103
|
+
let requestId: string | undefined;
|
|
104
|
+
if (response.body && response.headers.get('content-type')?.split(';')[0] === 'application/json') {
|
|
105
|
+
try {
|
|
106
|
+
const body = await boundedBody(responseBytes(response.body, 'INVALID_RESPONSE'), 4096);
|
|
107
|
+
const parsed = JSON.parse(await body.text());
|
|
108
|
+
if (writing && status === 409 && parsed?.code === 'PUSH_DUPLICATE') code = 'PUSH_OUTCOME_UNKNOWN';
|
|
109
|
+
else if (allowed?.includes(parsed?.code)) code = parsed.code;
|
|
110
|
+
if (typeof parsed?.requestId === 'string' &&
|
|
111
|
+
/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu.test(parsed.requestId)) {
|
|
112
|
+
requestId = parsed.requestId;
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Untrusted error bodies never become part of a client error.
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
await response.body?.cancel().catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
if (writing && (!allowed || status >= 500)) code = 'PUSH_OUTCOME_UNKNOWN';
|
|
121
|
+
return new GitSmartHttpError(code, status, requestId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createGitSmartHttp(options: GitSmartHttpOptions) {
|
|
125
|
+
const url = remoteUrl(options.remote);
|
|
126
|
+
const connection = {...options.connection};
|
|
127
|
+
checkConnection(connection);
|
|
128
|
+
const {getSession, maxRequestBytes = MAX_GIT_REQUEST_BYTES, signal: operationSignal, fetch: send = fetch} = options;
|
|
129
|
+
if (!Number.isSafeInteger(maxRequestBytes) || maxRequestBytes <= 0 || maxRequestBytes > MAX_GIT_REQUEST_BYTES) {
|
|
130
|
+
throw new GitSmartHttpError('INVALID_CONNECTION');
|
|
131
|
+
}
|
|
132
|
+
const proxyPath = connection.proxyOrigin + '/v1/git' + url.slice('https://github.com'.length);
|
|
133
|
+
const request = async ({url: requested, method = 'GET', body, signal}: Request) => {
|
|
134
|
+
const path = requested.startsWith(url + '/') ? requested.slice(url.length) : '';
|
|
135
|
+
const discovery = method === 'GET' && !body;
|
|
136
|
+
const rpc = method === 'POST' && Boolean(body);
|
|
137
|
+
const service = ['git-upload-pack', 'git-receive-pack'].find(name =>
|
|
138
|
+
(discovery && path === `/info/refs?service=${name}`) || (rpc && path === `/${name}`));
|
|
139
|
+
if (!service) throw new GitSmartHttpError('INVALID_REQUEST');
|
|
140
|
+
|
|
141
|
+
const abort = signal && operationSignal ? AbortSignal.any([signal, operationSignal]) : signal ?? operationSignal;
|
|
142
|
+
checkAbort(abort);
|
|
143
|
+
const payload = body ? await boundedBody(body, maxRequestBytes, abort) : undefined;
|
|
144
|
+
checkAbort(abort);
|
|
145
|
+
let session: Session | null;
|
|
146
|
+
try {
|
|
147
|
+
session = await getSession();
|
|
148
|
+
} catch {
|
|
149
|
+
checkAbort(abort);
|
|
150
|
+
throw new GitSmartHttpError('SESSION_UNAVAILABLE');
|
|
151
|
+
}
|
|
152
|
+
checkAbort(abort);
|
|
153
|
+
if (!session) throw new GitSmartHttpError('SESSION_REQUIRED');
|
|
154
|
+
if (!Number.isFinite(session.expiresAt) || session.expiresAt <= Date.now()) {
|
|
155
|
+
throw new GitSmartHttpError('SESSION_EXPIRED');
|
|
156
|
+
}
|
|
157
|
+
if (session.connection?.mode !== connection.mode || session.connection.proxyOrigin !== connection.proxyOrigin ||
|
|
158
|
+
typeof session.bearer !== 'string' || !/^[A-Za-z0-9._~+\/-]+=*$/u.test(session.bearer)) {
|
|
159
|
+
throw new GitSmartHttpError('INVALID_SESSION');
|
|
160
|
+
}
|
|
161
|
+
const contentType = `application/x-${service}-${discovery ? 'advertisement' : 'result'}`;
|
|
162
|
+
const headers = new Headers({Authorization: `Bearer ${session.bearer}`, Accept: contentType});
|
|
163
|
+
if (payload) headers.set('Content-Type', `application/x-${service}-request`);
|
|
164
|
+
const writing = rpc && service === 'git-receive-pack';
|
|
165
|
+
if (writing) headers.set('Git-Push-Id', crypto.randomUUID());
|
|
166
|
+
|
|
167
|
+
let response: Response;
|
|
168
|
+
try {
|
|
169
|
+
response = await send(proxyPath + path, {
|
|
170
|
+
method, headers, body: payload, signal: abort, credentials: 'omit', redirect: 'error', cache: 'no-store',
|
|
171
|
+
referrerPolicy: 'no-referrer'
|
|
172
|
+
});
|
|
173
|
+
} catch {
|
|
174
|
+
if (writing) throw new GitSmartHttpError('PUSH_OUTCOME_UNKNOWN');
|
|
175
|
+
checkAbort(abort);
|
|
176
|
+
throw new GitSmartHttpError('NETWORK_ERROR');
|
|
177
|
+
}
|
|
178
|
+
if (response.status !== 200) throw await proxyError(response, writing);
|
|
179
|
+
if (!response.body || response.headers.get('content-type') !== contentType) {
|
|
180
|
+
await response.body?.cancel().catch(() => {});
|
|
181
|
+
throw new GitSmartHttpError(writing ? 'PUSH_OUTCOME_UNKNOWN' : 'INVALID_RESPONSE');
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
url: requested, method, headers: {'content-type': contentType},
|
|
185
|
+
statusCode: 200, statusMessage: 'OK',
|
|
186
|
+
body: responseBytes(response.body, writing ? 'PUSH_OUTCOME_UNKNOWN' : 'NETWORK_ERROR', abort)
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
return {url, corsProxy: '' as const, http: {request} satisfies HttpClient};
|
|
190
|
+
}
|
package/src/style.css
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
.twgit-backdrop { position: fixed; inset: 0; z-index: 65000; background: #0006; display: flex; align-items: center; justify-content: center; }
|
|
2
|
+
.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; }
|
|
3
|
+
.twgit-panel > header { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid GrayText; }
|
|
4
|
+
.twgit-panel > header h2 { margin: 0 auto 0 0; font-size: 18px; }
|
|
5
|
+
.twgit-panel h3 { font-size: 14px; margin: 12px 0 5px; }
|
|
6
|
+
.twgit-panel button { color: inherit; background: Canvas; border: 1px solid GrayText; border-radius: 4px; padding: 4px 8px; cursor: pointer; font: inherit; text-align: start; }
|
|
7
|
+
.twgit-panel button:disabled { opacity: .5; cursor: default; }
|
|
8
|
+
.twgit-panel button:focus-visible, .twgit-panel summary:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
|
|
9
|
+
.twgit-panel button[aria-pressed="true"] { color: HighlightText; background: Highlight; }
|
|
10
|
+
.twgit-context { display: flex; gap: 8px 20px; flex-wrap: wrap; padding: 8px 16px; }
|
|
11
|
+
.twgit-connection { margin: 0 16px 8px; max-height: 45%; overflow: auto; }
|
|
12
|
+
.twgit-content { display: grid; grid-template-columns: minmax(230px, 30%) minmax(0, 1fr); flex: 1; min-height: 0; }
|
|
13
|
+
.twgit-panel aside { padding: 0 14px 14px; overflow: auto; border-right: 1px solid GrayText; }
|
|
14
|
+
.twgit-panel aside ul { list-style: none; padding: 0; margin: 4px 0; }
|
|
15
|
+
.twgit-panel aside li { overflow-wrap: anywhere; }
|
|
16
|
+
.twgit-panel summary { cursor: pointer; }
|
|
17
|
+
.twgit-panel .twgit-file { width: 100%; border: 0; margin: 1px 0; }
|
|
18
|
+
.twgit-file span { display: inline-block; width: 1.4em; font-family: monospace; }
|
|
19
|
+
.twgit-diff { padding: 0 16px 16px; min-width: 0; overflow: auto; }
|
|
20
|
+
.twgit-diff .diff { font-size: 12px; }
|
|
21
|
+
.twgit-history { list-style: none; margin: 0; padding: 0; }
|
|
22
|
+
.twgit-history li { padding: 5px 0; }
|
|
23
|
+
.twgit-history small { display: block; font-size: 11px; }
|
|
24
|
+
.twgit-panel > footer { font-size: 11px; padding: 8px 16px; border-top: 1px solid GrayText; }
|
|
25
|
+
.twgit-error { margin: 6px 16px; color: #a11919; padding: 8px; background: #fff0f0; }
|
|
26
|
+
@media (max-width: 700px) { .twgit-content { grid-template-columns: minmax(180px, 42%) minmax(0, 1fr); } .twgit-panel > header { flex-wrap: wrap; } }
|
|
27
|
+
.twgit-view-switch { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 10px; }
|
|
28
|
+
.twgit-review-unreadable { margin: 6px 0; padding: 8px; background: color-mix(in srgb, #d9822b 16%, Canvas); }
|
|
29
|
+
.twgit-review-comparison .twgit-review-overview dl { margin: 4px 0 8px; }
|
package/src/styles.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module '*.css';
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"jsx": "react",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"emitDeclarationOnly": true,
|
|
13
|
+
"rootDir": "src",
|
|
14
|
+
"outDir": "dist",
|
|
15
|
+
"skipLibCheck": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
|
18
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {defineConfig, esmExternalRequirePlugin} from 'vite';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
resolve: {alias: {buffer: 'buffer/'}},
|
|
5
|
+
build: {
|
|
6
|
+
target: 'es2017',
|
|
7
|
+
// The Editor host minifies the final bundle. Preserve class bindings for webpack 4.
|
|
8
|
+
minify: false,
|
|
9
|
+
license: {fileName: 'THIRD_PARTY_NOTICES.md'},
|
|
10
|
+
lib: {
|
|
11
|
+
entry: {
|
|
12
|
+
index: 'src/browser.ts',
|
|
13
|
+
repository: 'src/repository.ts',
|
|
14
|
+
'smart-http': 'src/smart-http.ts',
|
|
15
|
+
'project-source-diff': 'src/project-source-diff.tsx'
|
|
16
|
+
},
|
|
17
|
+
formats: ['es'],
|
|
18
|
+
fileName: (_format, name) => `${name}.js`,
|
|
19
|
+
cssFileName: 'style'
|
|
20
|
+
},
|
|
21
|
+
rolldownOptions: {
|
|
22
|
+
// The bundled JSX runtime must resolve React to the host's copy in ESM builds.
|
|
23
|
+
plugins: [esmExternalRequirePlugin({external: ['react', 'react-dom']})]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|