@kubohiroya/turbowarp-plus-editor-git 0.12.0 → 0.13.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 +15 -0
- package/README.md +15 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +38 -12
- package/dist/{scratch-block-diff-renderer-e_drVlHE.js → project-comparison-D1rHzlCw.js} +79 -1
- package/dist/project-comparison.d.ts +31 -0
- package/dist/project-source-diff.d.ts +2 -0
- package/dist/project-source-diff.js +2 -2
- package/dist/style.css +4 -0
- package/package.json +2 -1
- package/src/index.tsx +42 -5
- package/src/project-comparison.tsx +88 -0
- package/src/project-source-diff.tsx +2 -0
- package/src/style.css +4 -0
- package/test/smart-http-fixture.js +178 -0
package/README.ja.md
CHANGED
|
@@ -16,6 +16,8 @@ Node.js 26 で `npm ci` と `npm test` を実行します。Vite でライブラ
|
|
|
16
16
|
|
|
17
17
|
## Smart HTTP アダプター
|
|
18
18
|
|
|
19
|
+
`test/smart-http-fixture.js` は、`git-http-backend` を IPv4 のループバックで動かす検証用のフィクスチャです。Node.js の標準モジュールだけを使い、利用側のブラウザーテストが同じ版を使えるよう配布物に含めます。
|
|
20
|
+
|
|
19
21
|
`@kubohiroya/turbowarp-plus-editor-git/smart-http` の `createGitSmartHttp` に、Git 設定から選んだ GitHub の HTTPS リモート、信頼済みの接続先(`mode` と `proxyOrigin`)、現在のプロキシセッションか `null` を返す関数を渡します。auth パッケージの `GitAuthProvider` を使えます。Git パッケージは必要なセッションの型だけを構造的に参照するため、読み取り専用パネルとアダプターは auth パッケージと独立してインストールできます。
|
|
20
22
|
|
|
21
23
|
```ts
|
|
@@ -112,6 +114,19 @@ const diffFile = new Blob([patch], {type: 'text/x-diff'});
|
|
|
112
114
|
|
|
113
115
|
どちらかの版を作品として読めないときは理由を示し、`fallback` を表示します。`fallback` がなければエラーだけを示します。
|
|
114
116
|
|
|
117
|
+
### 比べてから選ぶダイアログ
|
|
118
|
+
|
|
119
|
+
`openProjectComparison(document, options)` は、見出しの付いた複数の比較を 1 つのモーダルに並べ、利用者に操作を選んでもらいます。各 `sections` は `ProjectSourceComparison` と同じ `before`・`after` を取ります。選ばれた `actions` の `id` で解決し、閉じるか Escape では `null` で解決します。最初の操作にフォーカスを置くので、失われるものが少ない操作を先に並べます。作品フォルダは、未保存の編集があるときに「あなたの未保存の編集」と「フォルダでの変更」を並べるのに使います。
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const choice = await openProjectComparison(document, {
|
|
123
|
+
title: '外部の変更と未保存の編集',
|
|
124
|
+
sections: [{title: 'あなたの未保存の編集', before: base, after: edited}, {title: 'フォルダでの変更', before: base, after: disk}],
|
|
125
|
+
actions: [{id: 'keep', label: '編集を残す'}, {id: 'discard', label: '編集を破棄してフォルダから読み込む'}],
|
|
126
|
+
renderBlock, locale: 'ja'
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
115
130
|
## 公開
|
|
116
131
|
|
|
117
132
|
npm への公開は `.github/workflows/release.yml` が行う。`package.json` の版と同じ `v<版>` のタグを
|
package/README.md
CHANGED
|
@@ -16,6 +16,8 @@ The panel structure and local Git approach were informed by [duff](https://githu
|
|
|
16
16
|
|
|
17
17
|
## Smart HTTP adapter
|
|
18
18
|
|
|
19
|
+
`test/smart-http-fixture.js` runs `git-http-backend` on the IPv4 loopback for tests. It uses only Node.js built-in modules and ships in the package so browser tests of a host use the fixture of the same version.
|
|
20
|
+
|
|
19
21
|
Import `createGitSmartHttp` from `@kubohiroya/turbowarp-plus-editor-git/smart-http`. Pass the selected GitHub HTTPS remote from Git config, a trusted connection (`mode` and `proxyOrigin`), and a function that returns the current proxy session or `null`. A `GitAuthProvider` from the auth package satisfies this contract. The Git package uses the session fields structurally, so the read-only panel and the adapter can be installed independently of the auth package.
|
|
20
22
|
|
|
21
23
|
```ts
|
|
@@ -35,6 +37,19 @@ Each receive-pack POST carries a new UUID v4 in `Git-Push-Id`. A browser replay
|
|
|
35
37
|
|
|
36
38
|
When the Editor host supplies connection settings, the Local Git panel offers fetch, fast-forward pull and push for the current branch and its existing GitHub HTTPS `origin`. Authorize push when connecting to enable the explicit push action. Fetch updates Git metadata. Pull requires a stopped project, a clean worktree, and regular non-executable files. Pull stages the changes, rechecks the local state, and then applies them. Reopen the project from the panel after a successful pull.
|
|
37
39
|
|
|
40
|
+
## Comparing before choosing
|
|
41
|
+
|
|
42
|
+
`openProjectComparison(document, options)` lays several titled comparisons of `project.source.json` texts in one modal and asks the user to choose an action. Each of `sections` takes the same `before` and `after` as `ProjectSourceComparison`. The promise resolves with the chosen action's `id`, or with `null` when the dialog is closed or Escape is pressed. The first action receives focus, so list the one that loses the least first. The project folder uses it to show "Your unsaved edits" next to "Changes in the folder" before replacing unsaved edits.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
const choice = await openProjectComparison(document, {
|
|
46
|
+
title: 'External changes and your unsaved edits',
|
|
47
|
+
sections: [{title: 'Your unsaved edits', before: base, after: edited}, {title: 'Changes in the folder', before: base, after: disk}],
|
|
48
|
+
actions: [{id: 'keep', label: 'Keep My Edits'}, {id: 'discard', label: 'Discard Edits and Load from Folder'}],
|
|
49
|
+
renderBlock
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
38
53
|
## Release
|
|
39
54
|
|
|
40
55
|
`.github/workflows/release.yml` publishes to npm. Push a `v<version>` tag that names the
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export { createEditorBlockLocator } from './editor-block-locator.js';
|
|
|
6
6
|
export { createScratchBlocksDiffRenderer } from './scratch-block-diff-renderer.js';
|
|
7
7
|
export type { ScratchBlockDiffRendererOptions, ScratchBlocksPort } from './scratch-block-diff-renderer.js';
|
|
8
8
|
export { openRepository, createReadOnlyFs, GitPanelError } from './repository.js';
|
|
9
|
+
export { openProjectComparison } from './project-comparison.js';
|
|
10
|
+
export type { ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection } from './project-comparison.js';
|
|
9
11
|
export type { Repository, Snapshot, FileChange, FileDiff } from './repository.js';
|
|
10
12
|
export interface GitHost {
|
|
11
13
|
connectionSettings?: GitConnectionSettings;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { S as parseProjectSource, a as PullRequestError, c as ProjectSourceComparison, d as organizeProjectChanges, f as projectPropertyLabel, i as createScratchBlocksDiffLocator, l as ProjectSourceReview, n as openProjectComparison, o as createGitHubPullRequests, r as createScratchBlocksDiffRenderer, s as gitHubRepositoryFromOrigin, v as useAssetImage, y as useCostumeImage } from "./project-comparison-D1rHzlCw.js";
|
|
2
2
|
import { n as init_objectSpread2, r as __commonJSMin, t as _objectSpread2$1 } from "./objectSpread2-Dkd68nJH.js";
|
|
3
3
|
import { i as createReadOnlyFs, r as GitPanelError, t as openRepository } from "./repository-CtvWYPQ8.js";
|
|
4
4
|
import * as m from "react";
|
|
@@ -3557,6 +3557,8 @@ function GitPanel({ vm, document, host, controller }) {
|
|
|
3557
3557
|
const alive = useRef(true);
|
|
3558
3558
|
const opening = useRef(false);
|
|
3559
3559
|
const dialog = useRef(null);
|
|
3560
|
+
const shownRef = useRef(false);
|
|
3561
|
+
const deferred = useRef(false);
|
|
3560
3562
|
const reviewing = useRef(review);
|
|
3561
3563
|
reviewing.current = review;
|
|
3562
3564
|
const handled = useRef(null);
|
|
@@ -3582,6 +3584,11 @@ function GitPanel({ vm, document, host, controller }) {
|
|
|
3582
3584
|
} catch (error) {
|
|
3583
3585
|
if (epoch.current !== ticket) return;
|
|
3584
3586
|
const code = error instanceof GitPanelError ? error.code : "failed";
|
|
3587
|
+
if (automatic && code === "changed") {
|
|
3588
|
+
handled.current = null;
|
|
3589
|
+
setState((previous) => _objectSpread2$1(_objectSpread2$1({}, previous), {}, { busy: false }));
|
|
3590
|
+
return;
|
|
3591
|
+
}
|
|
3585
3592
|
const message = labels[code] || labels.failed;
|
|
3586
3593
|
setState((previous) => _objectSpread2$1(_objectSpread2$1({}, previous), {}, {
|
|
3587
3594
|
error: automatic && previous.error ? previous.error : message,
|
|
@@ -3590,6 +3597,7 @@ function GitPanel({ vm, document, host, controller }) {
|
|
|
3590
3597
|
}));
|
|
3591
3598
|
} finally {
|
|
3592
3599
|
running.current = false;
|
|
3600
|
+
if (epoch.current !== ticket && !refreshPending.current && alive.current) setState((previous) => _objectSpread2$1(_objectSpread2$1({}, previous), {}, { busy: false }));
|
|
3593
3601
|
if (automatic) {
|
|
3594
3602
|
background.current = null;
|
|
3595
3603
|
settle();
|
|
@@ -3632,20 +3640,29 @@ function GitPanel({ vm, document, host, controller }) {
|
|
|
3632
3640
|
};
|
|
3633
3641
|
controller.refresh = async (automatic = false) => {
|
|
3634
3642
|
if (!alive.current || !current.current.repository) return;
|
|
3643
|
+
if (!shownRef.current) {
|
|
3644
|
+
deferred.current = true;
|
|
3645
|
+
return;
|
|
3646
|
+
}
|
|
3635
3647
|
if (running.current) {
|
|
3636
3648
|
if (!automatic) refreshPending.current = true;
|
|
3637
3649
|
return;
|
|
3638
3650
|
}
|
|
3639
3651
|
await run(async () => {
|
|
3640
3652
|
const { repository, project } = current.current;
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3653
|
+
for (let attempt = 1;; attempt++) try {
|
|
3654
|
+
const snapshot = await repository.read(project !== null && project !== void 0 ? project : "");
|
|
3655
|
+
return {
|
|
3656
|
+
snapshot,
|
|
3657
|
+
changes: snapshot.changes,
|
|
3658
|
+
selected: null,
|
|
3659
|
+
diff: null,
|
|
3660
|
+
commit: null
|
|
3661
|
+
};
|
|
3662
|
+
} catch (error) {
|
|
3663
|
+
if (!(error instanceof GitPanelError && error.code === "changed") || attempt >= 5) throw error;
|
|
3664
|
+
await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
|
|
3665
|
+
}
|
|
3649
3666
|
}, automatic);
|
|
3650
3667
|
};
|
|
3651
3668
|
useEffect(() => {
|
|
@@ -3662,7 +3679,7 @@ function GitPanel({ vm, document, host, controller }) {
|
|
|
3662
3679
|
selected: null,
|
|
3663
3680
|
diff: null,
|
|
3664
3681
|
commit: null,
|
|
3665
|
-
busy:
|
|
3682
|
+
busy: running.current
|
|
3666
3683
|
}));
|
|
3667
3684
|
queueMicrotask(() => void controller.refresh());
|
|
3668
3685
|
}
|
|
@@ -3675,6 +3692,13 @@ function GitPanel({ vm, document, host, controller }) {
|
|
|
3675
3692
|
unsubscribe === null || unsubscribe === void 0 || unsubscribe();
|
|
3676
3693
|
};
|
|
3677
3694
|
}, [host]);
|
|
3695
|
+
useEffect(() => {
|
|
3696
|
+
shownRef.current = shown;
|
|
3697
|
+
if (shown && deferred.current) {
|
|
3698
|
+
deferred.current = false;
|
|
3699
|
+
controller.refresh();
|
|
3700
|
+
}
|
|
3701
|
+
}, [shown]);
|
|
3678
3702
|
useEffect(() => {
|
|
3679
3703
|
const repository = state.repository;
|
|
3680
3704
|
if (!shown || !repository) return;
|
|
@@ -3683,7 +3707,8 @@ function GitPanel({ vm, document, host, controller }) {
|
|
|
3683
3707
|
let checking = false;
|
|
3684
3708
|
const check = async () => {
|
|
3685
3709
|
const { snapshot } = current.current;
|
|
3686
|
-
|
|
3710
|
+
const hidden = !dialog.current || !dialog.current.getClientRects().length;
|
|
3711
|
+
if (stopped || checking || running.current || document.visibilityState === "hidden" || hidden || !snapshot || current.current.repository !== repository) return;
|
|
3687
3712
|
checking = true;
|
|
3688
3713
|
try {
|
|
3689
3714
|
const probe = await repository.probe(snapshot.scope);
|
|
@@ -3978,6 +4003,7 @@ function GitPanel({ vm, document, host, controller }) {
|
|
|
3978
4003
|
}) : /* @__PURE__ */ React.createElement("p", null, labels.select))));
|
|
3979
4004
|
if (slot) return createPortal(/* @__PURE__ */ React.createElement("div", {
|
|
3980
4005
|
className: "twgit-panel twgit-panel-tab",
|
|
4006
|
+
ref: dialog,
|
|
3981
4007
|
role: "region",
|
|
3982
4008
|
"aria-label": labels.title
|
|
3983
4009
|
}, content), slot.element);
|
|
@@ -4039,4 +4065,4 @@ function renderGitMenu(createElement, { vm, locale = "en", MenuItem, MenuSection
|
|
|
4039
4065
|
}
|
|
4040
4066
|
}, labelsFor(locale).menu));
|
|
4041
4067
|
}
|
|
4042
|
-
export { GitPanelError, createEditorBlockLocator, createReadOnlyFs, createScratchBlocksDiffLocator, createScratchBlocksDiffRenderer, mountGitPanel, openRepository, renderGitMenu };
|
|
4068
|
+
export { GitPanelError, createEditorBlockLocator, createReadOnlyFs, createScratchBlocksDiffLocator, createScratchBlocksDiffRenderer, mountGitPanel, openProjectComparison, openRepository, renderGitMenu };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { n as init_objectSpread2, t as _objectSpread2 } from "./objectSpread2-Dkd68nJH.js";
|
|
2
2
|
import { a as createTwoFilesPatch, i as isAssetName, n as assetsDirectoryParts, o as hasOnlyUnixLineEndings, r as folderSettingsLimit, s as hasOnlyWinLineEndings, t as assetLimit } from "./project-asset-paths-KV715791.js";
|
|
3
3
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { render, unmountComponentAtNode } from "react-dom";
|
|
4
5
|
// node_modules/diff/libesm/patch/line-endings.js
|
|
5
6
|
function unixToWin(patch) {
|
|
6
7
|
if (Array.isArray(patch)) return patch.map((p) => unixToWin(p));
|
|
@@ -2414,4 +2415,81 @@ function createScratchBlocksDiffRenderer(options) {
|
|
|
2414
2415
|
};
|
|
2415
2416
|
};
|
|
2416
2417
|
}
|
|
2417
|
-
|
|
2418
|
+
// src/project-comparison.tsx
|
|
2419
|
+
init_objectSpread2();
|
|
2420
|
+
var focusable = "button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex=\"0\"]";
|
|
2421
|
+
/** A modal that lays project comparisons side by side in sections and asks the user to choose an action. */
|
|
2422
|
+
function ProjectComparisonDialog({ title, description, notes = [], sections, actions, closeLabel = "Close", renderBlock, locale = "en", onChoose }) {
|
|
2423
|
+
const dialog = useRef(null);
|
|
2424
|
+
const first = useRef(null);
|
|
2425
|
+
useEffect(() => {
|
|
2426
|
+
var _dialog$current, _ref, _first$current;
|
|
2427
|
+
const previous = (_dialog$current = dialog.current) === null || _dialog$current === void 0 ? void 0 : _dialog$current.ownerDocument.activeElement;
|
|
2428
|
+
(_ref = (_first$current = first.current) !== null && _first$current !== void 0 ? _first$current : dialog.current) === null || _ref === void 0 || _ref.focus();
|
|
2429
|
+
return () => {
|
|
2430
|
+
if (previous === null || previous === void 0 ? void 0 : previous.isConnected) previous.focus();
|
|
2431
|
+
};
|
|
2432
|
+
}, []);
|
|
2433
|
+
const keyDown = (event) => {
|
|
2434
|
+
if (event.key === "Escape") {
|
|
2435
|
+
event.stopPropagation();
|
|
2436
|
+
onChoose(null);
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
if (event.key !== "Tab") return;
|
|
2440
|
+
const items = [...event.currentTarget.querySelectorAll(focusable)].filter((item) => item.getClientRects().length > 0);
|
|
2441
|
+
const start = items[0], end = items[items.length - 1];
|
|
2442
|
+
const active = event.currentTarget.ownerDocument.activeElement;
|
|
2443
|
+
if (event.shiftKey && (active === start || active === event.currentTarget)) {
|
|
2444
|
+
event.preventDefault();
|
|
2445
|
+
end === null || end === void 0 || end.focus();
|
|
2446
|
+
} else if (!event.shiftKey && active === end) {
|
|
2447
|
+
event.preventDefault();
|
|
2448
|
+
start === null || start === void 0 || start.focus();
|
|
2449
|
+
}
|
|
2450
|
+
};
|
|
2451
|
+
return /* @__PURE__ */ React.createElement("div", { className: "twgit-backdrop" }, /* @__PURE__ */ React.createElement("div", {
|
|
2452
|
+
className: "twgit-panel twgit-comparison",
|
|
2453
|
+
ref: dialog,
|
|
2454
|
+
tabIndex: -1,
|
|
2455
|
+
role: "dialog",
|
|
2456
|
+
"aria-modal": "true",
|
|
2457
|
+
"aria-label": title,
|
|
2458
|
+
onKeyDown: keyDown
|
|
2459
|
+
}, /* @__PURE__ */ React.createElement("header", null, /* @__PURE__ */ React.createElement("h2", null, title), /* @__PURE__ */ React.createElement("button", {
|
|
2460
|
+
type: "button",
|
|
2461
|
+
onClick: () => onChoose(null)
|
|
2462
|
+
}, closeLabel)), /* @__PURE__ */ React.createElement("div", { className: "twgit-comparison-body" }, description && /* @__PURE__ */ React.createElement("p", null, description), notes.map((note) => /* @__PURE__ */ React.createElement("p", {
|
|
2463
|
+
key: note,
|
|
2464
|
+
role: "note"
|
|
2465
|
+
}, note)), sections.map((section) => /* @__PURE__ */ React.createElement("section", {
|
|
2466
|
+
key: section.title,
|
|
2467
|
+
"aria-label": section.title,
|
|
2468
|
+
"data-twgit-comparison": ""
|
|
2469
|
+
}, /* @__PURE__ */ React.createElement("h3", null, section.title), /* @__PURE__ */ React.createElement(ProjectSourceComparison, {
|
|
2470
|
+
before: section.before,
|
|
2471
|
+
after: section.after,
|
|
2472
|
+
renderBlock,
|
|
2473
|
+
locale
|
|
2474
|
+
})))), /* @__PURE__ */ React.createElement("footer", { className: "twgit-comparison-actions" }, actions.map((action, index) => /* @__PURE__ */ React.createElement("button", {
|
|
2475
|
+
key: action.id,
|
|
2476
|
+
type: "button",
|
|
2477
|
+
ref: index === 0 ? first : void 0,
|
|
2478
|
+
"data-twgit-action": action.id,
|
|
2479
|
+
onClick: () => onChoose(action.id)
|
|
2480
|
+
}, action.label)))));
|
|
2481
|
+
}
|
|
2482
|
+
/** Shows {@link ProjectComparisonDialog} and resolves with the chosen action's id, or `null` when it is closed. */
|
|
2483
|
+
function openProjectComparison(document, options) {
|
|
2484
|
+
const root = document.createElement("div");
|
|
2485
|
+
document.body.append(root);
|
|
2486
|
+
return new Promise((resolve) => {
|
|
2487
|
+
const choose = (action) => {
|
|
2488
|
+
unmountComponentAtNode(root);
|
|
2489
|
+
root.remove();
|
|
2490
|
+
resolve(action);
|
|
2491
|
+
};
|
|
2492
|
+
render(/* @__PURE__ */ React.createElement(ProjectComparisonDialog, _objectSpread2(_objectSpread2({}, options), {}, { onChoose: choose })), root);
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
export { readProjectSourceDiff as C, parseProjectSource as S, currentCostume as _, PullRequestError as a, compareProjectBlocks as b, ProjectSourceComparison as c, organizeProjectChanges as d, projectPropertyLabel as f, usePreviewSync as g, createPreviewSync as h, createScratchBlocksDiffLocator as i, ProjectSourceReview as l, BlockPreview as m, openProjectComparison as n, createGitHubPullRequests as o, compareProjectForReview as p, createScratchBlocksDiffRenderer as r, gitHubRepositoryFromOrigin as s, ProjectComparisonDialog as t, assetFileName as u, useAssetImage as v, createProjectSourceDiff as x, useCostumeImage as y };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ProjectBlockRenderer } from './scratch-block-diff-renderer.js';
|
|
3
|
+
/** One comparison of two project.source.json texts, shown under its own heading. */
|
|
4
|
+
export interface ProjectComparisonSection {
|
|
5
|
+
title: string;
|
|
6
|
+
before: string | null;
|
|
7
|
+
after: string | null;
|
|
8
|
+
}
|
|
9
|
+
export interface ProjectComparisonAction {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ProjectComparisonOptions {
|
|
14
|
+
title: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Notes shown after the description, such as edits the comparison cannot show. */
|
|
17
|
+
notes?: readonly string[];
|
|
18
|
+
sections: readonly ProjectComparisonSection[];
|
|
19
|
+
/** The first action is the one the dialog focuses. Closing the dialog or pressing Escape chooses none. */
|
|
20
|
+
actions: readonly ProjectComparisonAction[];
|
|
21
|
+
closeLabel?: string;
|
|
22
|
+
renderBlock: ProjectBlockRenderer;
|
|
23
|
+
locale?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ProjectComparisonDialogProps extends ProjectComparisonOptions {
|
|
26
|
+
onChoose(action: string | null): void;
|
|
27
|
+
}
|
|
28
|
+
/** A modal that lays project comparisons side by side in sections and asks the user to choose an action. */
|
|
29
|
+
export declare function ProjectComparisonDialog({ title, description, notes, sections, actions, closeLabel, renderBlock, locale, onChoose }: ProjectComparisonDialogProps): React.JSX.Element;
|
|
30
|
+
/** Shows {@link ProjectComparisonDialog} and resolves with the chosen action's id, or `null` when it is closed. */
|
|
31
|
+
export declare function openProjectComparison(document: Document, options: ProjectComparisonOptions): Promise<string | null>;
|
|
@@ -4,6 +4,8 @@ import type { ProjectBlockRenderer } from './scratch-block-diff-renderer.js';
|
|
|
4
4
|
import './project-source-diff.css';
|
|
5
5
|
export { createGitHubPullRequests, gitHubRepositoryFromOrigin } from './github-pull-requests.js';
|
|
6
6
|
export { ProjectSourceComparison, ProjectSourceReview } from './project-source-review.js';
|
|
7
|
+
export { ProjectComparisonDialog, openProjectComparison } from './project-comparison.js';
|
|
8
|
+
export type { ProjectComparisonAction, ProjectComparisonDialogProps, ProjectComparisonOptions, ProjectComparisonSection } from './project-comparison.js';
|
|
7
9
|
export type { ProjectSourceComparisonProps, ProjectSourceReviewFile, ProjectSourceReviewProps } from './project-source-review.js';
|
|
8
10
|
export { createScratchBlocksDiffLocator } from './block-diff-location.js';
|
|
9
11
|
export type { ProjectBlockLocation, ProjectBlockLocator, ScratchBlockLocationWorkspace } from './block-diff-location.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as readProjectSourceDiff, _ as currentCostume, b as compareProjectBlocks, c as ProjectSourceComparison, d as organizeProjectChanges, f as projectPropertyLabel, g as usePreviewSync, h as createPreviewSync, i as createScratchBlocksDiffLocator, l as ProjectSourceReview, m as BlockPreview, n as openProjectComparison, o as createGitHubPullRequests, p as compareProjectForReview, r as createScratchBlocksDiffRenderer, s as gitHubRepositoryFromOrigin, t as ProjectComparisonDialog, u as assetFileName, x as createProjectSourceDiff } from "./project-comparison-D1rHzlCw.js";
|
|
2
2
|
import { n as init_objectSpread2, t as _objectSpread2 } from "./objectSpread2-Dkd68nJH.js";
|
|
3
3
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
// src/project-source-diff.tsx
|
|
@@ -169,4 +169,4 @@ function ProjectSourceBlockDiff({ patch, beforeSource, renderBlock, locateBlock,
|
|
|
169
169
|
})
|
|
170
170
|
}, labels.more));
|
|
171
171
|
}
|
|
172
|
-
export { ProjectSourceBlockDiff, ProjectSourceComparison, ProjectSourceReview, assetFileName, compareProjectForReview, createGitHubPullRequests, createPreviewSync, createProjectSourceDiff, createScratchBlocksDiffLocator, createScratchBlocksDiffRenderer, currentCostume, gitHubRepositoryFromOrigin, organizeProjectChanges, projectPropertyLabel };
|
|
172
|
+
export { ProjectComparisonDialog, ProjectSourceBlockDiff, ProjectSourceComparison, ProjectSourceReview, assetFileName, compareProjectForReview, createGitHubPullRequests, createPreviewSync, createProjectSourceDiff, createScratchBlocksDiffLocator, createScratchBlocksDiffRenderer, currentCostume, gitHubRepositoryFromOrigin, openProjectComparison, organizeProjectChanges, projectPropertyLabel };
|
package/dist/style.css
CHANGED
|
@@ -179,4 +179,8 @@
|
|
|
179
179
|
.twgit-git-details > .twgit-connection-body { left: auto; right: 0; }
|
|
180
180
|
/* In a layout tab the panel fills the tab and leaves the Editor beside it. */
|
|
181
181
|
.twgit-panel.twgit-panel-tab { width: 100%; height: 100%; border: 0; border-radius: 0; box-shadow: none; }
|
|
182
|
+
.twgit-comparison-body { flex: 1; overflow: auto; padding: 12px 16px; }
|
|
183
|
+
.twgit-comparison-body > p { margin: 0 0 8px; }
|
|
184
|
+
.twgit-comparison-body > section { margin-top: 16px; }
|
|
185
|
+
.twgit-comparison-actions { display: flex; gap: 8px; justify-content: flex-end; padding: 10px 16px; border-top: 1px solid GrayText; }
|
|
182
186
|
/*$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.13.0",
|
|
4
4
|
"description": "Read-only local Git panel for TurboWarp Plus Editor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"tsconfig.json",
|
|
37
37
|
"README.ja.md",
|
|
38
38
|
"THIRD_PARTY_NOTICES.md",
|
|
39
|
+
"test/smart-http-fixture.js",
|
|
39
40
|
"README.md",
|
|
40
41
|
"LICENSE"
|
|
41
42
|
],
|
package/src/index.tsx
CHANGED
|
@@ -22,6 +22,8 @@ export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js'
|
|
|
22
22
|
export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch-block-diff-renderer.js';
|
|
23
23
|
|
|
24
24
|
export {openRepository, createReadOnlyFs, GitPanelError} from './repository.js';
|
|
25
|
+
export {openProjectComparison} from './project-comparison.js';
|
|
26
|
+
export type {ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
|
|
25
27
|
export type {Repository, Snapshot, FileChange, FileDiff} from './repository.js';
|
|
26
28
|
|
|
27
29
|
export interface GitHost {
|
|
@@ -93,6 +95,9 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
93
95
|
const alive = useRef(true);
|
|
94
96
|
const opening = useRef(false);
|
|
95
97
|
const dialog = useRef<HTMLDivElement>(null);
|
|
98
|
+
// A closed panel reads nothing; a refresh asked while it is closed waits until it opens again.
|
|
99
|
+
const shownRef = useRef(false);
|
|
100
|
+
const deferred = useRef(false);
|
|
96
101
|
const reviewing = useRef(review);
|
|
97
102
|
reviewing.current = review;
|
|
98
103
|
// The last fingerprint acted on, so a failing read or an open diff is not retried on every check.
|
|
@@ -115,10 +120,20 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
115
120
|
} catch (error) {
|
|
116
121
|
if (epoch.current !== ticket) return;
|
|
117
122
|
const code = error instanceof GitPanelError ? error.code : 'failed';
|
|
123
|
+
// A background read that met another tool still writing tries again on the next check, without an alert.
|
|
124
|
+
if (automatic && code === 'changed') {
|
|
125
|
+
handled.current = null;
|
|
126
|
+
setState(previous => ({...previous, busy: false}));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
118
129
|
const message = labels[code as keyof typeof labels] || labels.failed;
|
|
119
130
|
setState(previous => ({...previous, error: automatic && previous.error ? previous.error : message, busy: false, diff: null}));
|
|
120
131
|
} finally {
|
|
121
132
|
running.current = false;
|
|
133
|
+
// A result dropped by a newer load still ends the busy state, unless a refresh follows at once.
|
|
134
|
+
if (epoch.current !== ticket && !refreshPending.current && alive.current) {
|
|
135
|
+
setState(previous => ({...previous, busy: false}));
|
|
136
|
+
}
|
|
122
137
|
if (automatic) {background.current = null; settle();}
|
|
123
138
|
if (refreshPending.current) {
|
|
124
139
|
refreshPending.current = false;
|
|
@@ -143,14 +158,26 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
143
158
|
};
|
|
144
159
|
controller.refresh = async (automatic = false) => {
|
|
145
160
|
if (!alive.current || !current.current.repository) return;
|
|
161
|
+
if (!shownRef.current) {
|
|
162
|
+
deferred.current = true;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
146
165
|
if (running.current) {
|
|
147
166
|
if (!automatic) refreshPending.current = true;
|
|
148
167
|
return;
|
|
149
168
|
}
|
|
150
169
|
await run(async () => {
|
|
151
170
|
const {repository, project} = current.current;
|
|
152
|
-
|
|
153
|
-
|
|
171
|
+
// A save or a Git command may still be finishing its writes; read again briefly before reporting a change.
|
|
172
|
+
for (let attempt = 1; ; attempt++) {
|
|
173
|
+
try {
|
|
174
|
+
const snapshot = await repository!.read(project ?? '');
|
|
175
|
+
return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null};
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!(error instanceof GitPanelError && error.code === 'changed') || attempt >= 5) throw error;
|
|
178
|
+
await new Promise(resolve => setTimeout(resolve, 200 * attempt));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
154
181
|
}, automatic);
|
|
155
182
|
};
|
|
156
183
|
useEffect(() => {
|
|
@@ -159,13 +186,21 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
159
186
|
setDirty(host.isProjectDirty());
|
|
160
187
|
if (event === 'loaded' && !opening.current) {
|
|
161
188
|
epoch.current++;
|
|
189
|
+
// An operation still running stays busy; its end, or the refresh queued after it, clears the flag.
|
|
162
190
|
setState(previous => ({...previous, project: null, snapshot: null, changes: [],
|
|
163
|
-
selected: null, diff: null, commit: null, busy:
|
|
191
|
+
selected: null, diff: null, commit: null, busy: running.current}));
|
|
164
192
|
queueMicrotask(() => void controller.refresh());
|
|
165
193
|
}
|
|
166
194
|
});
|
|
167
195
|
return () => {alive.current = false; epoch.current++; remoteRequest.current?.abort(); unsubscribe?.();};
|
|
168
196
|
}, [host]);
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
shownRef.current = shown;
|
|
199
|
+
if (shown && deferred.current) {
|
|
200
|
+
deferred.current = false;
|
|
201
|
+
void controller.refresh();
|
|
202
|
+
}
|
|
203
|
+
}, [shown]);
|
|
169
204
|
// While the panel is open, notice Git commands, agents and editors that change the repository outside the Editor.
|
|
170
205
|
useEffect(() => {
|
|
171
206
|
const repository = state.repository;
|
|
@@ -175,7 +210,9 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
175
210
|
let checking = false;
|
|
176
211
|
const check = async () => {
|
|
177
212
|
const {snapshot} = current.current;
|
|
178
|
-
|
|
213
|
+
// A tab behind another one has no boxes; it reads again when it is shown.
|
|
214
|
+
const hidden = !dialog.current || !dialog.current.getClientRects().length;
|
|
215
|
+
if (stopped || checking || running.current || document.visibilityState === 'hidden' || hidden ||
|
|
179
216
|
!snapshot || current.current.repository !== repository) return;
|
|
180
217
|
checking = true;
|
|
181
218
|
try {
|
|
@@ -409,7 +446,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
409
446
|
</div>}
|
|
410
447
|
</>;
|
|
411
448
|
// In a tab the Editor stays usable beside the panel, so there is no backdrop, focus trap or Escape to close.
|
|
412
|
-
if (slot) return createPortal(<div className="twgit-panel twgit-panel-tab" role="region" aria-label={labels.title}>{content}</div>,
|
|
449
|
+
if (slot) return createPortal(<div className="twgit-panel twgit-panel-tab" ref={dialog} role="region" aria-label={labels.title}>{content}</div>,
|
|
413
450
|
slot.element);
|
|
414
451
|
return <div className="twgit-backdrop">
|
|
415
452
|
<div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React, {useEffect, useRef} from 'react';
|
|
2
|
+
import {render, unmountComponentAtNode} from 'react-dom';
|
|
3
|
+
import {ProjectSourceComparison} from './project-source-review.js';
|
|
4
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
5
|
+
|
|
6
|
+
/** One comparison of two project.source.json texts, shown under its own heading. */
|
|
7
|
+
export interface ProjectComparisonSection {
|
|
8
|
+
title: string;
|
|
9
|
+
before: string | null;
|
|
10
|
+
after: string | null;
|
|
11
|
+
}
|
|
12
|
+
export interface ProjectComparisonAction {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ProjectComparisonOptions {
|
|
17
|
+
title: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
/** Notes shown after the description, such as edits the comparison cannot show. */
|
|
20
|
+
notes?: readonly string[];
|
|
21
|
+
sections: readonly ProjectComparisonSection[];
|
|
22
|
+
/** The first action is the one the dialog focuses. Closing the dialog or pressing Escape chooses none. */
|
|
23
|
+
actions: readonly ProjectComparisonAction[];
|
|
24
|
+
closeLabel?: string;
|
|
25
|
+
renderBlock: ProjectBlockRenderer;
|
|
26
|
+
locale?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ProjectComparisonDialogProps extends ProjectComparisonOptions {
|
|
29
|
+
onChoose(action: string | null): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const focusable = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]';
|
|
33
|
+
|
|
34
|
+
/** A modal that lays project comparisons side by side in sections and asks the user to choose an action. */
|
|
35
|
+
export function ProjectComparisonDialog({title, description, notes = [], sections, actions, closeLabel = 'Close',
|
|
36
|
+
renderBlock, locale = 'en', onChoose}: ProjectComparisonDialogProps) {
|
|
37
|
+
const dialog = useRef<HTMLDivElement>(null);
|
|
38
|
+
const first = useRef<HTMLButtonElement>(null);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const previous = dialog.current?.ownerDocument.activeElement as HTMLElement | null;
|
|
41
|
+
(first.current ?? dialog.current)?.focus();
|
|
42
|
+
return () => {if (previous?.isConnected) previous.focus();};
|
|
43
|
+
}, []);
|
|
44
|
+
const keyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
45
|
+
if (event.key === 'Escape') {event.stopPropagation(); onChoose(null); return;}
|
|
46
|
+
if (event.key !== 'Tab') return;
|
|
47
|
+
const items = [...event.currentTarget.querySelectorAll<HTMLElement>(focusable)].filter(item => item.getClientRects().length > 0);
|
|
48
|
+
const start = items[0], end = items[items.length - 1];
|
|
49
|
+
const active = event.currentTarget.ownerDocument.activeElement;
|
|
50
|
+
if (event.shiftKey && (active === start || active === event.currentTarget)) {event.preventDefault(); end?.focus();}
|
|
51
|
+
else if (!event.shiftKey && active === end) {event.preventDefault(); start?.focus();}
|
|
52
|
+
};
|
|
53
|
+
return <div className="twgit-backdrop">
|
|
54
|
+
<div className="twgit-panel twgit-comparison" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true"
|
|
55
|
+
aria-label={title} onKeyDown={keyDown}>
|
|
56
|
+
<header>
|
|
57
|
+
<h2>{title}</h2>
|
|
58
|
+
<button type="button" onClick={() => onChoose(null)}>{closeLabel}</button>
|
|
59
|
+
</header>
|
|
60
|
+
<div className="twgit-comparison-body">
|
|
61
|
+
{description && <p>{description}</p>}
|
|
62
|
+
{notes.map(note => <p key={note} role="note">{note}</p>)}
|
|
63
|
+
{sections.map(section => <section key={section.title} aria-label={section.title} data-twgit-comparison="">
|
|
64
|
+
<h3>{section.title}</h3>
|
|
65
|
+
<ProjectSourceComparison before={section.before} after={section.after} renderBlock={renderBlock} locale={locale}/>
|
|
66
|
+
</section>)}
|
|
67
|
+
</div>
|
|
68
|
+
<footer className="twgit-comparison-actions">
|
|
69
|
+
{actions.map((action, index) => <button key={action.id} type="button" ref={index === 0 ? first : undefined}
|
|
70
|
+
data-twgit-action={action.id} onClick={() => onChoose(action.id)}>{action.label}</button>)}
|
|
71
|
+
</footer>
|
|
72
|
+
</div>
|
|
73
|
+
</div>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Shows {@link ProjectComparisonDialog} and resolves with the chosen action's id, or `null` when it is closed. */
|
|
77
|
+
export function openProjectComparison(document: Document, options: ProjectComparisonOptions): Promise<string | null> {
|
|
78
|
+
const root = document.createElement('div');
|
|
79
|
+
document.body.append(root);
|
|
80
|
+
return new Promise(resolve => {
|
|
81
|
+
const choose = (action: string | null) => {
|
|
82
|
+
unmountComponentAtNode(root);
|
|
83
|
+
root.remove();
|
|
84
|
+
resolve(action);
|
|
85
|
+
};
|
|
86
|
+
render(<ProjectComparisonDialog {...options} onChoose={choose}/>, root);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -9,6 +9,8 @@ import './project-source-diff.css';
|
|
|
9
9
|
|
|
10
10
|
export {createGitHubPullRequests, gitHubRepositoryFromOrigin} from './github-pull-requests.js';
|
|
11
11
|
export {ProjectSourceComparison, ProjectSourceReview} from './project-source-review.js';
|
|
12
|
+
export {ProjectComparisonDialog, openProjectComparison} from './project-comparison.js';
|
|
13
|
+
export type {ProjectComparisonAction, ProjectComparisonDialogProps, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
|
|
12
14
|
export type {ProjectSourceComparisonProps, ProjectSourceReviewFile, ProjectSourceReviewProps} from './project-source-review.js';
|
|
13
15
|
|
|
14
16
|
export {createScratchBlocksDiffLocator} from './block-diff-location.js';
|
package/src/style.css
CHANGED
|
@@ -57,3 +57,7 @@
|
|
|
57
57
|
.twgit-git-details > .twgit-connection-body { left: auto; right: 0; }
|
|
58
58
|
/* In a layout tab the panel fills the tab and leaves the Editor beside it. */
|
|
59
59
|
.twgit-panel.twgit-panel-tab { width: 100%; height: 100%; border: 0; border-radius: 0; box-shadow: none; }
|
|
60
|
+
.twgit-comparison-body { flex: 1; overflow: auto; padding: 12px 16px; }
|
|
61
|
+
.twgit-comparison-body > p { margin: 0 0 8px; }
|
|
62
|
+
.twgit-comparison-body > section { margin-top: 16px; }
|
|
63
|
+
.twgit-comparison-actions { display: flex; gap: 8px; justify-content: flex-end; padding: 10px 16px; border-top: 1px solid GrayText; }
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {execFileSync} from 'node:child_process';
|
|
2
|
+
import {createServer} from 'node:http';
|
|
3
|
+
import {once} from 'node:events';
|
|
4
|
+
import {randomUUID} from 'node:crypto';
|
|
5
|
+
import {mkdtemp, mkdir, rm, writeFile} from 'node:fs/promises';
|
|
6
|
+
import {tmpdir} from 'node:os';
|
|
7
|
+
import {join} from 'node:path';
|
|
8
|
+
|
|
9
|
+
// Git's detached auto maintenance can repack loose objects while the code under test is reading them.
|
|
10
|
+
const gitEnv = {...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_COUNT: '2',
|
|
11
|
+
GIT_CONFIG_KEY_0: 'maintenance.auto', GIT_CONFIG_VALUE_0: 'false', GIT_CONFIG_KEY_1: 'gc.auto', GIT_CONFIG_VALUE_1: '0'};
|
|
12
|
+
const backend = join(execFileSync('git', ['--exec-path'], {encoding: 'utf8'}).trim(), 'git-http-backend');
|
|
13
|
+
export const remote = 'https://github.com/fixture/work.git';
|
|
14
|
+
export const readDiscovery = '/info/refs?service=git-upload-pack';
|
|
15
|
+
export const writeDiscovery = '/info/refs?service=git-receive-pack';
|
|
16
|
+
const routePrefix = '/v1/git/fixture/work.git';
|
|
17
|
+
const maxBody = 1024 * 1024;
|
|
18
|
+
const pushIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
19
|
+
|
|
20
|
+
export function git(dir, ...args) {
|
|
21
|
+
return execFileSync('git', args, {cwd: dir, encoding: 'utf8', env: gitEnv, stdio: ['ignore', 'pipe', 'pipe']}).trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function commit(dir, message) {
|
|
25
|
+
git(dir, 'add', 'project.txt');
|
|
26
|
+
git(dir, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-qm', message);
|
|
27
|
+
return git(dir, 'rev-parse', 'HEAD');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function startFixture(t) {
|
|
31
|
+
const root = await mkdtemp(join(tmpdir(), 'tw-git-http-'));
|
|
32
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
33
|
+
await mkdir(join(root, 'fixture'));
|
|
34
|
+
const bare = join(root, 'fixture/work.git');
|
|
35
|
+
const seed = join(root, 'seed');
|
|
36
|
+
const client = join(root, 'client');
|
|
37
|
+
git(root, 'init', '-q', '--bare', '-b', 'main', bare);
|
|
38
|
+
git(bare, 'config', 'http.receivepack', 'true');
|
|
39
|
+
git(root, 'init', '-q', '-b', 'main', seed);
|
|
40
|
+
await writeFile(join(seed, 'project.txt'), 'initial\n');
|
|
41
|
+
commit(seed, 'initial');
|
|
42
|
+
git(seed, 'push', bare, 'main');
|
|
43
|
+
git(root, 'clone', '-q', bare, client);
|
|
44
|
+
git(client, 'remote', 'set-url', 'origin', remote);
|
|
45
|
+
git(client, 'config', 'http.corsProxy', 'https://rejected.example.invalid');
|
|
46
|
+
|
|
47
|
+
const origins = new Set(['https://editor.example.invalid']);
|
|
48
|
+
const sessions = new Map([
|
|
49
|
+
['fixture-read', {write: false, mode: 'desktop'}],
|
|
50
|
+
['fixture-write', {write: true, mode: 'desktop'}],
|
|
51
|
+
['fixture-expired', {expires: true}],
|
|
52
|
+
['fixture-cloudflare', {write: true, mode: 'cloudflare'}]
|
|
53
|
+
]);
|
|
54
|
+
const requests = [];
|
|
55
|
+
const upstream = [];
|
|
56
|
+
const pushIds = new WeakMap();
|
|
57
|
+
const fixture = {root, bare, seed, client, origins, sessions, requests, upstream, redirect: false, dropPush: false};
|
|
58
|
+
const reply = (res, status, code) => {
|
|
59
|
+
res.writeHead(status, {'Content-Type': 'application/json'});
|
|
60
|
+
res.end(JSON.stringify({code, requestId: randomUUID()}));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
async function handle(req, res) {
|
|
64
|
+
const {url, method, headers} = req;
|
|
65
|
+
requests.push({url, method, headers});
|
|
66
|
+
if (headers.host !== new URL(fixture.origin).host || req.socket.remoteAddress !== '127.0.0.1' ||
|
|
67
|
+
!origins.has(headers.origin)) return reply(res, 403, 'FORBIDDEN');
|
|
68
|
+
|
|
69
|
+
res.setHeader('Access-Control-Allow-Origin', headers.origin);
|
|
70
|
+
res.setHeader('Vary', 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers');
|
|
71
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
72
|
+
const effectiveMethod = method === 'OPTIONS' ? headers['access-control-request-method'] : method;
|
|
73
|
+
const path = url.startsWith(routePrefix + '/') ? url.slice(routePrefix.length) : '';
|
|
74
|
+
const service = ['git-upload-pack', 'git-receive-pack'].find(service =>
|
|
75
|
+
(effectiveMethod === 'GET' && path === '/info/refs?service=' + service) ||
|
|
76
|
+
(effectiveMethod === 'POST' && path === '/' + service));
|
|
77
|
+
if (!service) return reply(res, 400, 'BAD_REQUEST');
|
|
78
|
+
const push = effectiveMethod === 'POST' && service === 'git-receive-pack';
|
|
79
|
+
const requestType = 'application/x-' + service + '-request';
|
|
80
|
+
if (method === 'OPTIONS') {
|
|
81
|
+
const allowed = effectiveMethod === 'GET' ? ['authorization'] : ['authorization', 'content-type'];
|
|
82
|
+
if (push) allowed.push('git-push-id');
|
|
83
|
+
const asked = (headers['access-control-request-headers'] ?? '').toLowerCase().split(',').map(name => name.trim());
|
|
84
|
+
if (asked.some(name => !allowed.includes(name))) return reply(res, 403, 'FORBIDDEN');
|
|
85
|
+
res.writeHead(204, {'Access-Control-Allow-Methods': effectiveMethod, 'Access-Control-Allow-Headers': allowed.join(', ')});
|
|
86
|
+
return res.end();
|
|
87
|
+
}
|
|
88
|
+
const session = headers.authorization?.startsWith('Bearer ') && sessions.get(headers.authorization.slice(7));
|
|
89
|
+
if (!session) return reply(res, 401, 'SESSION_REQUIRED');
|
|
90
|
+
if (session.expires) return reply(res, 401, 'SESSION_EXPIRED');
|
|
91
|
+
if (session.mode !== 'desktop' || (service === 'git-receive-pack' && !session.write)) return reply(res, 403, 'FORBIDDEN');
|
|
92
|
+
if (fixture.redirect) {
|
|
93
|
+
if (fixture.redirect === 'upstream') return reply(res, 409, 'REDIRECT_REJECTED');
|
|
94
|
+
res.writeHead(307, {Location: fixture.origin + '/redirect-target'});
|
|
95
|
+
return res.end();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let input;
|
|
99
|
+
if (method === 'POST') {
|
|
100
|
+
if (headers['content-type'] !== requestType) return reply(res, 400, 'BAD_REQUEST');
|
|
101
|
+
if (!/^\d+$/u.test(headers['content-length'] ?? '')) return reply(res, 411, 'LENGTH_REQUIRED');
|
|
102
|
+
const expected = Number(headers['content-length']);
|
|
103
|
+
if (expected > maxBody) return reply(res, 413, 'PAYLOAD_TOO_LARGE');
|
|
104
|
+
let size = 0;
|
|
105
|
+
const chunks = [];
|
|
106
|
+
for await (const chunk of req) {
|
|
107
|
+
size += chunk.length;
|
|
108
|
+
if (size > expected) return reply(res, 400, 'BAD_REQUEST');
|
|
109
|
+
chunks.push(chunk);
|
|
110
|
+
}
|
|
111
|
+
if (size !== expected) return reply(res, 400, 'BAD_REQUEST');
|
|
112
|
+
input = Buffer.concat(chunks);
|
|
113
|
+
}
|
|
114
|
+
const pushId = headers['git-push-id'];
|
|
115
|
+
if (push ? !pushIdPattern.test(pushId ?? '') || req.headersDistinct['git-push-id']?.length !== 1 :
|
|
116
|
+
pushId !== undefined) return reply(res, 400, 'BAD_REQUEST');
|
|
117
|
+
if (push) {
|
|
118
|
+
let claimed = pushIds.get(session);
|
|
119
|
+
if (!claimed) {
|
|
120
|
+
claimed = new Set();
|
|
121
|
+
pushIds.set(session, claimed);
|
|
122
|
+
}
|
|
123
|
+
if (claimed.has(pushId)) return reply(res, 409, 'PUSH_DUPLICATE');
|
|
124
|
+
claimed.add(pushId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const [pathInfo, query = ''] = ('/fixture/work.git' + path).split('?');
|
|
128
|
+
// Model the credential boundary and invoke Git's CGI backend locally.
|
|
129
|
+
const authorization = 'Basic ' + Buffer.from('x-access-token:fixture-upstream-token').toString('base64');
|
|
130
|
+
upstream.push({method, path: pathInfo, query, authorization});
|
|
131
|
+
const output = execFileSync(backend, [], {
|
|
132
|
+
input,
|
|
133
|
+
env: {...gitEnv, GIT_PROJECT_ROOT: root, GIT_HTTP_EXPORT_ALL: '1', REQUEST_METHOD: method,
|
|
134
|
+
PATH_INFO: pathInfo, QUERY_STRING: query, REMOTE_USER: 'fixture-user',
|
|
135
|
+
CONTENT_TYPE: headers['content-type'] ?? '', CONTENT_LENGTH: headers['content-length'] ?? '0'}
|
|
136
|
+
});
|
|
137
|
+
const dropPush = fixture.dropPush && method === 'POST' && service === 'git-receive-pack';
|
|
138
|
+
if (dropPush && fixture.dropPush !== 'after-headers') {
|
|
139
|
+
res.destroy();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const end = output.indexOf('\r\n\r\n');
|
|
143
|
+
if (end < 0) throw new Error('Git CGI response had no header boundary');
|
|
144
|
+
const lines = output.subarray(0, end).toString().split('\r\n');
|
|
145
|
+
const statusLine = lines.find(line => line.startsWith('Status: '));
|
|
146
|
+
const contentType = lines.find(line => line.startsWith('Content-Type: '));
|
|
147
|
+
if (contentType) res.setHeader('Content-Type', contentType.slice(14));
|
|
148
|
+
if (dropPush) {
|
|
149
|
+
res.setHeader('Content-Length', output.length - end - 3);
|
|
150
|
+
res.setHeader('Connection', 'close');
|
|
151
|
+
}
|
|
152
|
+
res.writeHead(statusLine ? Number(statusLine.slice(8, 11)) : 200);
|
|
153
|
+
res.end(output.subarray(end + 4));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const server = createServer((req, res) => {
|
|
157
|
+
handle(req, res).catch(() => {
|
|
158
|
+
if (!res.headersSent) reply(res, 502, 'UPSTREAM_FAILURE');
|
|
159
|
+
else res.destroy();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
server.listen(0, '127.0.0.1');
|
|
163
|
+
await once(server, 'listening');
|
|
164
|
+
t.after(() => new Promise(resolve => {server.close(resolve); server.closeAllConnections();}));
|
|
165
|
+
fixture.origin = 'http://127.0.0.1:' + server.address().port;
|
|
166
|
+
return fixture;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const proxyPath = path => routePrefix + path;
|
|
170
|
+
|
|
171
|
+
export function adapterOptions(fixture, bearer = 'fixture-write') {
|
|
172
|
+
const connection = {mode: 'desktop', proxyOrigin: fixture.origin};
|
|
173
|
+
return {
|
|
174
|
+
remote, connection,
|
|
175
|
+
getSession: async () => ({connection, bearer, expiresAt: Date.now() + 60_000}),
|
|
176
|
+
fetch: (url, init) => fetch(url, {...init, headers: {...Object.fromEntries(init.headers), Origin: 'https://editor.example.invalid'}})
|
|
177
|
+
};
|
|
178
|
+
}
|