@kubohiroya/turbowarp-plus-editor-git 0.0.0-stage → 0.7.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 +105 -0
- package/README.md +35 -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 +17 -0
- package/dist/browser.d.ts +4 -0
- package/dist/connection-settings.d.ts +35 -0
- package/dist/file-diff-view.d.ts +16 -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 +3556 -0
- package/dist/libesm-OWRWzmu_.js +739 -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-source-diff.d.ts +22 -0
- package/dist/project-source-diff.js +169 -0
- package/dist/project-source-patch.d.ts +39 -0
- package/dist/project-source-review-model.d.ts +20 -0
- package/dist/project-source-review.d.ts +34 -0
- package/dist/pull-request-panel.d.ts +8 -0
- package/dist/remote-operations.d.ts +5 -0
- package/dist/repository-DplMQiuo.js +17212 -0
- package/dist/repository.d.ts +61 -0
- package/dist/repository.js +1 -0
- package/dist/scratch-block-diff-renderer-CLYoOIB2.js +1464 -0
- package/dist/scratch-block-diff-renderer.d.ts +65 -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 +109 -0
- package/package.json +60 -4
- package/src/block-diff-location.ts +63 -0
- package/src/block-diff-preview.tsx +36 -0
- package/src/browser.ts +5 -0
- package/src/connection-settings.tsx +103 -0
- package/src/file-diff-view.tsx +43 -0
- package/src/filesystem.ts +125 -0
- package/src/github-pull-requests.ts +162 -0
- package/src/index.tsx +349 -0
- package/src/messages.ts +64 -0
- package/src/mutation-fs.ts +204 -0
- package/src/project-source-diff.css +79 -0
- package/src/project-source-diff.tsx +132 -0
- package/src/project-source-patch.ts +126 -0
- package/src/project-source-review-model.ts +113 -0
- package/src/project-source-review.tsx +217 -0
- package/src/pull-request-panel.tsx +134 -0
- package/src/remote-operations.ts +90 -0
- package/src/repository.ts +308 -0
- package/src/scratch-block-diff-renderer.ts +213 -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,217 @@
|
|
|
1
|
+
import React, {useMemo, useState} from 'react';
|
|
2
|
+
import {BlockPreview} from './block-diff-preview.js';
|
|
3
|
+
import {isRecord, parseProjectSource, readProjectSourceDiff} from './project-source-patch.js';
|
|
4
|
+
import type {BlockSide} from './project-source-patch.js';
|
|
5
|
+
import {compareProjectForReview} from './project-source-review-model.js';
|
|
6
|
+
import type {ScriptReview} from './project-source-review-model.js';
|
|
7
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
8
|
+
|
|
9
|
+
type Comparison = ReturnType<typeof compareProjectForReview>;
|
|
10
|
+
|
|
11
|
+
export type ProjectSourceReviewFile = {
|
|
12
|
+
path: string;
|
|
13
|
+
summary?: string;
|
|
14
|
+
previousPath?: string;
|
|
15
|
+
unavailable?: boolean;
|
|
16
|
+
} & ({patch: string; beforeSource?: string} | {status: 'added' | 'removed' | 'updated'});
|
|
17
|
+
|
|
18
|
+
export interface ProjectSourceReviewProps {
|
|
19
|
+
title: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
verification?: readonly string[];
|
|
22
|
+
files: readonly ProjectSourceReviewFile[];
|
|
23
|
+
renderBlock: ProjectBlockRenderer;
|
|
24
|
+
locale?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ja = {
|
|
28
|
+
review: '変更レビュー', fullDescription: 'PR 本文を表示', checks: '確認してほしいこと', files: '変更ファイル', targets: '影響するステージ・スプライト',
|
|
29
|
+
scripts: 'スクリプトの比較', reviewed: '確認済み', reviewedScripts: 'スクリプト確認済み', before: '変更前', after: '変更後',
|
|
30
|
+
updated: '変更', added: '追加', removed: '削除', reorganized: '再編',
|
|
31
|
+
absent: 'この版にはスクリプトがありません', loading: 'スクリプトを描画中…',
|
|
32
|
+
unavailable: 'このスクリプトを描画できません。',
|
|
33
|
+
related: '関連ファイル', properties: 'コード以外の変更', invalid: 'この作品の差分を読み込めません。',
|
|
34
|
+
flag: '緑の旗が押されたとき', broadcast: 'を受け取ったとき', key: 'キーが押されたとき',
|
|
35
|
+
click: 'このスプライトが押されたとき', script: 'スクリプト', noChanges: '作品に内容の変更はありません。',
|
|
36
|
+
comparison: '作品の変更', unreadable: '作品データとして読み込めないため、テキストの差分を表示します。'
|
|
37
|
+
};
|
|
38
|
+
const en: typeof ja = {
|
|
39
|
+
review: 'Change review', fullDescription: 'Show full PR description', checks: 'Review checklist', files: 'Changed files', targets: 'Affected stages and sprites',
|
|
40
|
+
scripts: 'Script comparisons', reviewed: 'Reviewed', reviewedScripts: 'Reviewed scripts', before: 'Before', after: 'After',
|
|
41
|
+
updated: 'Changed', added: 'Added', removed: 'Removed', reorganized: 'Reorganized',
|
|
42
|
+
absent: 'No script in this version', loading: 'Rendering script…',
|
|
43
|
+
unavailable: 'This script could not be rendered.',
|
|
44
|
+
related: 'Related file', properties: 'Changes outside the code', invalid: 'This project diff could not be read.',
|
|
45
|
+
flag: 'When green flag clicked', broadcast: 'received', key: 'When key pressed',
|
|
46
|
+
click: 'When this sprite clicked', script: 'Script', noChanges: 'No changes to the project contents.',
|
|
47
|
+
comparison: 'Project changes', unreadable: 'This file could not be read as project data, so its text diff is shown.'
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const properties = new Map<string, readonly [string, string]>([
|
|
51
|
+
['variables', ['変数', 'Variables']], ['lists', ['リスト', 'Lists']], ['broadcasts', ['メッセージ', 'Broadcast messages']],
|
|
52
|
+
['costumes', ['コスチューム・背景', 'Costumes and backdrops']], ['sounds', ['音', 'Sounds']],
|
|
53
|
+
['comments', ['コメント', 'Comments']], ['monitors', ['モニター', 'Monitors']], ['extensions', ['拡張機能', 'Extensions']],
|
|
54
|
+
['x', ['X 座標', 'X position']], ['y', ['Y 座標', 'Y position']], ['visible', ['表示状態', 'Visibility']],
|
|
55
|
+
['direction', ['向き', 'Direction']], ['size', ['大きさ', 'Size']], ['currentCostume', ['表示するコスチューム', 'Selected costume']]
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function scriptTitle(side: BlockSide, labels: typeof ja) {
|
|
59
|
+
const block = side.target.blocks[side.id];
|
|
60
|
+
if (!isRecord(block)) return labels.script;
|
|
61
|
+
if (block.opcode === 'event_whenflagclicked') return labels.flag;
|
|
62
|
+
if (block.opcode === 'event_whenthisspriteclicked') return labels.click;
|
|
63
|
+
if (block.opcode === 'event_whenkeypressed') return labels.key;
|
|
64
|
+
if (block.opcode === 'event_whenbroadcastreceived') {
|
|
65
|
+
const field = isRecord(block.fields) ? block.fields.BROADCAST_OPTION : undefined;
|
|
66
|
+
const message = Array.isArray(field) && typeof field[0] === 'string' ? field[0] : '';
|
|
67
|
+
if (message) return labels === ja ? `「${message}」${labels.broadcast}` : `“${message}” ${labels.broadcast}`;
|
|
68
|
+
}
|
|
69
|
+
return typeof block.opcode === 'string' ? block.opcode : labels.script;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock}: {
|
|
73
|
+
script: ScriptReview; labels: typeof ja; reviewed?: boolean; setReviewed?(value: boolean): void;
|
|
74
|
+
renderBlock: ProjectBlockRenderer;
|
|
75
|
+
}) {
|
|
76
|
+
const [open, setOpen] = useState(false);
|
|
77
|
+
const status = !script.before.length ? 'added' : !script.after.length ? 'removed' :
|
|
78
|
+
script.before.length !== 1 || script.after.length !== 1 ? 'reorganized' : 'updated';
|
|
79
|
+
const beforeTitle = script.before[0] && scriptTitle(script.before[0], labels);
|
|
80
|
+
const afterTitle = script.after[0] && scriptTitle(script.after[0], labels);
|
|
81
|
+
const title = beforeTitle && afterTitle && beforeTitle !== afterTitle ? beforeTitle + ' → ' + afterTitle : afterTitle ?? beforeTitle;
|
|
82
|
+
return <details className="twgit-review-script" onToggle={event => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
|
83
|
+
<summary>
|
|
84
|
+
<span className="twgit-review-status">{labels[status]}</span>
|
|
85
|
+
<strong>{title}</strong>
|
|
86
|
+
{status === 'reorganized' && <small>{script.before.length} → {script.after.length}</small>}
|
|
87
|
+
{reviewed && <small>{labels.reviewed}</small>}
|
|
88
|
+
</summary>
|
|
89
|
+
{open && <>
|
|
90
|
+
<div className="twgit-block-diff-sides">
|
|
91
|
+
{(['before', 'after'] as const).map(version => <div key={version}>
|
|
92
|
+
{script[version].length ? script[version].map((side, index, sides) => <BlockPreview key={side.id} side={side}
|
|
93
|
+
title={sides.length > 1 ? `${labels[version]} ${index + 1}` : labels[version]}
|
|
94
|
+
marks={side.marks} renderBlock={renderBlock} withContext labels={labels}/>) :
|
|
95
|
+
<BlockPreview title={labels[version]} renderBlock={renderBlock} withContext labels={labels}/>}
|
|
96
|
+
</div>)}
|
|
97
|
+
</div>
|
|
98
|
+
{setReviewed && <label className="twgit-review-checked"><input type="checkbox" checked={reviewed}
|
|
99
|
+
onChange={event => setReviewed(event.currentTarget.checked)}/> {labels.reviewed}</label>}
|
|
100
|
+
</>}
|
|
101
|
+
</details>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function propertyNames(keys: readonly string[], labels: typeof ja) {
|
|
105
|
+
return keys.map(key => properties.get(key)?.[labels === ja ? 0 : 1] ?? key).join(' · ');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Lists the changed stages and sprites of one project file, each with its script comparisons. */
|
|
109
|
+
function ComparisonTargets({comparison, labels, renderBlock, review}: {
|
|
110
|
+
comparison: Comparison; labels: typeof ja; renderBlock: ProjectBlockRenderer;
|
|
111
|
+
review?: {file: string; keys: ReadonlySet<string>; set(key: string, value: boolean): void};
|
|
112
|
+
}) {
|
|
113
|
+
return <>
|
|
114
|
+
{comparison.properties.length > 0 && <p>{labels.properties} · {propertyNames(comparison.properties, labels)}</p>}
|
|
115
|
+
{!comparison.targets.length && !comparison.properties.length && <p>{labels.noChanges}</p>}
|
|
116
|
+
{comparison.targets.map(target => <section className="twgit-review-target" key={JSON.stringify([target.isStage, target.name])}>
|
|
117
|
+
<h5>{target.name}{target.scripts.length > 0 && <small>{labels.scripts} · {target.scripts.length}</small>}</h5>
|
|
118
|
+
{target.properties.length > 0 && <p>{labels.properties} · {propertyNames(target.properties, labels)}</p>}
|
|
119
|
+
{target.scripts.map(script => {
|
|
120
|
+
const key = JSON.stringify([review?.file, target.isStage, target.name, script.id]);
|
|
121
|
+
return <ScriptComparison key={key} script={script} labels={labels} renderBlock={renderBlock}
|
|
122
|
+
reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}/>;
|
|
123
|
+
})}
|
|
124
|
+
</section>)}
|
|
125
|
+
</>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const countScripts = (comparison: Comparison) => comparison.targets.reduce((count, target) => count + target.scripts.length, 0);
|
|
129
|
+
|
|
130
|
+
export interface ProjectSourceComparisonProps {
|
|
131
|
+
/** The earlier project.source.json, or `null` when the file did not exist yet. */
|
|
132
|
+
before: string | null;
|
|
133
|
+
/** The later project.source.json, or `null` when the file was removed. */
|
|
134
|
+
after: string | null;
|
|
135
|
+
renderBlock: ProjectBlockRenderer;
|
|
136
|
+
locale?: string;
|
|
137
|
+
/** Shown after an explanation when either version cannot be read as a project. Without it, only an error is shown. */
|
|
138
|
+
fallback?: React.ReactNode;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Compares two versions of a project.source.json by stage, sprite and script, as the pull request review does. */
|
|
142
|
+
export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback}: ProjectSourceComparisonProps) {
|
|
143
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
144
|
+
const comparison = useMemo(() => {
|
|
145
|
+
try {
|
|
146
|
+
return compareProjectForReview(parseProjectSource(before), parseProjectSource(after));
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}, [before, after]);
|
|
151
|
+
if (!comparison) return fallback === undefined ? <p role="alert">{labels.invalid}</p> :
|
|
152
|
+
<><p role="alert" className="twgit-review-unreadable">{labels.unreadable}</p>{fallback}</>;
|
|
153
|
+
return <section className="twgit-block-diff twgit-review twgit-review-comparison" aria-label={labels.comparison}>
|
|
154
|
+
<header className="twgit-review-overview">
|
|
155
|
+
<dl>
|
|
156
|
+
<div><dt>{labels.targets}</dt><dd>{comparison.targets.length}</dd></div>
|
|
157
|
+
<div><dt>{labels.scripts}</dt><dd>{countScripts(comparison)}</dd></div>
|
|
158
|
+
</dl>
|
|
159
|
+
</header>
|
|
160
|
+
<ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}/>
|
|
161
|
+
</section>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function ProjectSourceReview({title, description, verification, files, renderBlock, locale = 'en'}: ProjectSourceReviewProps) {
|
|
165
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
166
|
+
const entries = useMemo(() => files.map(file => {
|
|
167
|
+
if (!('patch' in file)) return {file};
|
|
168
|
+
try {
|
|
169
|
+
const projects = readProjectSourceDiff(file.patch, file.beforeSource);
|
|
170
|
+
return {file, comparison: projects ? compareProjectForReview(...projects) : {targets: [], properties: []}};
|
|
171
|
+
} catch {
|
|
172
|
+
return {file, invalid: true};
|
|
173
|
+
}
|
|
174
|
+
}), [files]);
|
|
175
|
+
const [checked, setChecked] = useState<{entries: typeof entries; keys: Set<string>}>();
|
|
176
|
+
const reviewed = checked?.entries === entries ? checked.keys : new Set<string>();
|
|
177
|
+
const setReviewed = (key: string, value: boolean) => setChecked(previous => {
|
|
178
|
+
const keys = new Set(previous?.entries === entries ? previous.keys : []);
|
|
179
|
+
if (value) keys.add(key);
|
|
180
|
+
else keys.delete(key);
|
|
181
|
+
return {entries, keys};
|
|
182
|
+
});
|
|
183
|
+
const targets = entries.reduce((total, entry) => total + (entry.comparison?.targets.length ?? 0), 0);
|
|
184
|
+
const scripts = entries.reduce((total, entry) => total + (entry.comparison ? countScripts(entry.comparison) : 0), 0);
|
|
185
|
+
const body = description?.trim();
|
|
186
|
+
const longBody = !!body && body.length > 420;
|
|
187
|
+
const excerpt = longBody ? body.slice(0, 250).trimEnd() + '…' : body;
|
|
188
|
+
return <section className="twgit-block-diff twgit-review" aria-label={labels.review}>
|
|
189
|
+
<header className="twgit-review-overview">
|
|
190
|
+
<p>{labels.review}</p>
|
|
191
|
+
<h2>{title}</h2>
|
|
192
|
+
{excerpt && <p className="twgit-review-description">{excerpt}</p>}
|
|
193
|
+
<dl>
|
|
194
|
+
<div><dt>{labels.files}</dt><dd>{entries.length}</dd></div>
|
|
195
|
+
<div><dt>{labels.targets}</dt><dd>{targets}</dd></div>
|
|
196
|
+
<div><dt>{labels.scripts}</dt><dd>{scripts}</dd></div>
|
|
197
|
+
{scripts > 0 && <div><dt>{labels.reviewedScripts}</dt><dd>{reviewed.size} / {scripts}</dd></div>}
|
|
198
|
+
</dl>
|
|
199
|
+
{longBody && <details className="twgit-review-body">
|
|
200
|
+
<summary>{labels.fullDescription}</summary>
|
|
201
|
+
<p className="twgit-review-description">{body}</p>
|
|
202
|
+
</details>}
|
|
203
|
+
{!!verification?.length && <div className="twgit-review-checklist">
|
|
204
|
+
<h3>{labels.checks}</h3>
|
|
205
|
+
<ul>{verification.map((item, index) => <li key={index}>{item}</li>)}</ul>
|
|
206
|
+
</div>}
|
|
207
|
+
</header>
|
|
208
|
+
<h3 className="twgit-review-files-title">{labels.files}</h3>
|
|
209
|
+
{entries.map(({file, comparison, invalid}) => <section key={file.path} className="twgit-review-file">
|
|
210
|
+
<header><h4>{file.previousPath ? file.previousPath + ' → ' + file.path : file.path}</h4>{file.summary && <p>{file.summary}</p>}</header>
|
|
211
|
+
{'status' in file && <p>{labels.related} · {labels[file.status]}</p>}
|
|
212
|
+
{(invalid || file.unavailable) && <p role="alert">{labels.invalid}</p>}
|
|
213
|
+
{comparison && <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}
|
|
214
|
+
review={{file: file.path, keys: reviewed, set: setReviewed}}/>}
|
|
215
|
+
</section>)}
|
|
216
|
+
</section>;
|
|
217
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, {useEffect, useMemo, useState} from 'react';
|
|
2
|
+
import {createGitHubPullRequests, gitHubRepositoryFromOrigin, PullRequestError} from './github-pull-requests.js';
|
|
3
|
+
import type {GitHubPullRequests} from './github-pull-requests.js';
|
|
4
|
+
import {ProjectSourceReview} from './project-source-review.js';
|
|
5
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
6
|
+
import type {Repository} from './repository.js';
|
|
7
|
+
|
|
8
|
+
const en = {
|
|
9
|
+
title: 'Pull requests', number: 'Pull request number', openNumber: 'Open pull request',
|
|
10
|
+
open: 'Open', closed: 'Closed', merged: 'Merged', draft: 'Draft',
|
|
11
|
+
prev: 'Previous page', next: 'Next page', empty: 'No pull requests on this page.',
|
|
12
|
+
loading: 'Reading GitHub…', select: 'Select a pull request to review its changes.', onGitHub: 'Open on GitHub',
|
|
13
|
+
noOrigin: 'Choose a repository whose origin is on GitHub.',
|
|
14
|
+
unavailable: 'GitHub could not read this repository or pull request. The panel supports public repositories.',
|
|
15
|
+
rateLimit: 'The public GitHub API rate limit was reached. Try again after the limit resets.',
|
|
16
|
+
failed: 'Could not read GitHub. Try again.', changed: 'The pull request changed while it was being read. Open it again.',
|
|
17
|
+
tooManyFiles: 'GitHub only returns the first 3,000 changed files. This pull request exceeds that limit.'
|
|
18
|
+
};
|
|
19
|
+
const ja: typeof en = {
|
|
20
|
+
title: 'プルリクエスト', number: 'プルリクエスト番号', openNumber: 'PR を開く',
|
|
21
|
+
open: 'オープン', closed: 'クローズ済み', merged: 'マージ済み', draft: 'ドラフト',
|
|
22
|
+
prev: '前のページ', next: '次のページ', empty: 'このページにプルリクエストはありません。',
|
|
23
|
+
loading: 'GitHub から取得中…', select: 'プルリクエストを選ぶと差分内容を確認できます。', onGitHub: 'GitHub で開く',
|
|
24
|
+
noOrigin: 'GitHub のリポジトリを origin に設定した作業フォルダを選んでください。',
|
|
25
|
+
unavailable: 'GitHub のリポジトリまたは PR を取得できません。公開リポジトリに対応しています。',
|
|
26
|
+
rateLimit: '公開 GitHub API の利用上限に達しました。上限の解除後に再試行してください。',
|
|
27
|
+
failed: 'GitHub から取得できませんでした。再試行してください。',
|
|
28
|
+
changed: '取得中にプルリクエストが更新されました。もう一度開いてください。',
|
|
29
|
+
tooManyFiles: '変更ファイルが GitHub API の取得上限である 3,000 件を超えています。'
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function errorMessage(error: unknown, labels: typeof en) {
|
|
33
|
+
return error instanceof PullRequestError ? labels[error.code] : labels.failed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Result<T> = {data: T; error?: never} | {data?: never; error: string};
|
|
37
|
+
type Listing = Awaited<ReturnType<GitHubPullRequests['list']>>;
|
|
38
|
+
type Pull = Awaited<ReturnType<GitHubPullRequests['load']>>;
|
|
39
|
+
|
|
40
|
+
function PullRequests({name, labels, locale, renderBlock}: {
|
|
41
|
+
name: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
|
|
42
|
+
}) {
|
|
43
|
+
const client = useMemo(() => createGitHubPullRequests(name), [name]);
|
|
44
|
+
const [page, setPage] = useState(1);
|
|
45
|
+
const [listing, setListing] = useState<{page: number; result: Result<Listing>}>();
|
|
46
|
+
const [input, setInput] = useState('');
|
|
47
|
+
const [selection, setSelection] = useState<{number: number}>();
|
|
48
|
+
const [loaded, setLoaded] = useState<{selection: NonNullable<typeof selection>; result: Result<Pull>}>();
|
|
49
|
+
const list = listing?.page === page ? listing.result : undefined;
|
|
50
|
+
const detail = loaded?.selection === selection ? loaded?.result : undefined;
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
setListing(undefined);
|
|
54
|
+
void client.list(page, controller.signal).then(data => {
|
|
55
|
+
if (!controller.signal.aborted) setListing({page, result: {data}});
|
|
56
|
+
}, error => {
|
|
57
|
+
if (!controller.signal.aborted) setListing({page, result: {error: errorMessage(error, labels)}});
|
|
58
|
+
});
|
|
59
|
+
return () => controller.abort();
|
|
60
|
+
}, [client, page, labels]);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!selection) return;
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
void client.load(selection.number, controller.signal).then(data => {
|
|
65
|
+
if (!controller.signal.aborted) setLoaded({selection, result: {data}});
|
|
66
|
+
}, error => {
|
|
67
|
+
if (!controller.signal.aborted) setLoaded({selection, result: {error: errorMessage(error, labels)}});
|
|
68
|
+
});
|
|
69
|
+
return () => controller.abort();
|
|
70
|
+
}, [client, selection, labels]);
|
|
71
|
+
const choose = (number: number) => {
|
|
72
|
+
if (!Number.isSafeInteger(number) || number < 1) return;
|
|
73
|
+
setSelection({number});
|
|
74
|
+
setInput(String(number));
|
|
75
|
+
};
|
|
76
|
+
const pr = detail?.data;
|
|
77
|
+
return <div className="twgit-content">
|
|
78
|
+
<aside>
|
|
79
|
+
<h3>{labels.title}</h3>
|
|
80
|
+
<p>{name}</p>
|
|
81
|
+
<form className="twgit-pr-number" onSubmit={event => {event.preventDefault(); choose(Number(input));}}>
|
|
82
|
+
<label>{labels.number}<input type="number" required min={1} max={Number.MAX_SAFE_INTEGER}
|
|
83
|
+
value={input} onChange={event => setInput(event.currentTarget.value)}/></label>
|
|
84
|
+
<button type="submit">{labels.openNumber}</button>
|
|
85
|
+
</form>
|
|
86
|
+
{!list && <p role="status">{labels.loading}</p>}
|
|
87
|
+
{list?.error && <p role="alert">{list.error}</p>}
|
|
88
|
+
{list?.data && <>
|
|
89
|
+
{!list.data.items.length && <p>{labels.empty}</p>}
|
|
90
|
+
<ul>{list.data.items.map(item => <li key={item.number}>
|
|
91
|
+
<button className="twgit-file" type="button" aria-pressed={selection?.number === item.number}
|
|
92
|
+
onClick={() => choose(item.number)}>#{item.number} {item.title}</button>
|
|
93
|
+
</li>)}</ul>
|
|
94
|
+
<div className="twgit-pr-paging">
|
|
95
|
+
<button type="button" disabled={page === 1} onClick={() => setPage(page - 1)}>{labels.prev}</button>
|
|
96
|
+
<button type="button" disabled={!list.data.more} onClick={() => setPage(page + 1)}>{labels.next}</button>
|
|
97
|
+
</div>
|
|
98
|
+
</>}
|
|
99
|
+
</aside>
|
|
100
|
+
<main className="twgit-diff">
|
|
101
|
+
{!selection && <p>{labels.select}</p>}
|
|
102
|
+
{selection && !detail && <p role="status">{labels.loading}</p>}
|
|
103
|
+
{detail?.error && <p role="alert">{detail.error}</p>}
|
|
104
|
+
{pr && <>
|
|
105
|
+
<p className="twgit-pr-revision">
|
|
106
|
+
<strong>#{pr.number} · {labels[pr.state]}</strong>
|
|
107
|
+
{pr.author && <span>{pr.author}</span>}
|
|
108
|
+
<code title={pr.head.sha}>{pr.head.ref} → {pr.base.ref}</code>
|
|
109
|
+
<a href={pr.url} target="_blank" rel="noreferrer">{labels.onGitHub}</a>
|
|
110
|
+
</p>
|
|
111
|
+
<ProjectSourceReview title={pr.title} description={pr.description} files={pr.files} renderBlock={renderBlock} locale={locale}/>
|
|
112
|
+
</>}
|
|
113
|
+
</main>
|
|
114
|
+
</div>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function PullRequestReviewPanel({repository, locale, renderBlock}: {
|
|
118
|
+
repository: Repository; locale: string; renderBlock: ProjectBlockRenderer;
|
|
119
|
+
}) {
|
|
120
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
121
|
+
const [origin, setOrigin] = useState<{repository: Repository; name: string | null; failed?: boolean}>();
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
let active = true;
|
|
124
|
+
void repository.readOrigin().then(value => {
|
|
125
|
+
if (active) setOrigin({repository, name: gitHubRepositoryFromOrigin(value)});
|
|
126
|
+
}, () => {
|
|
127
|
+
if (active) setOrigin({repository, name: null, failed: true});
|
|
128
|
+
});
|
|
129
|
+
return () => {active = false;};
|
|
130
|
+
}, [repository]);
|
|
131
|
+
if (origin?.repository !== repository) return <p role="status" className="twgit-context">{labels.loading}</p>;
|
|
132
|
+
if (!origin.name) return <p className="twgit-context" role="status">{origin.failed ? labels.failed : labels.noOrigin}</p>;
|
|
133
|
+
return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock}/>;
|
|
134
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as git from 'isomorphic-git';
|
|
2
|
+
import {GitPanelError} from './filesystem.js';
|
|
3
|
+
import {createMutationFs} from './mutation-fs.js';
|
|
4
|
+
import {createGitSmartHttp, GitSmartHttpError} from './smart-http.js';
|
|
5
|
+
import type {GitSmartHttpOptions} from './smart-http.js';
|
|
6
|
+
|
|
7
|
+
export type RemoteOperation = 'fetch' | 'pull' | 'push';
|
|
8
|
+
export type RemoteSession = NonNullable<Awaited<ReturnType<GitSmartHttpOptions['getSession']>>>;
|
|
9
|
+
export type GetRemoteSession = (remote: string, write: boolean) => Promise<RemoteSession | null>;
|
|
10
|
+
|
|
11
|
+
export async function runRemoteOperation(root: FileSystemDirectoryHandle, operation: RemoteOperation,
|
|
12
|
+
getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) {
|
|
13
|
+
const execute = async () => {
|
|
14
|
+
guard();
|
|
15
|
+
const transaction = createMutationFs(root, operation === 'pull');
|
|
16
|
+
const options = {fs: transaction.fs, dir: '/', gitdir: '/.git', cache: {}};
|
|
17
|
+
const ref = await git.currentBranch(options);
|
|
18
|
+
if (!ref) throw new GitPanelError('unsupported');
|
|
19
|
+
const config = (path: string) => git.getConfig({...options, path});
|
|
20
|
+
const remote = await config('remote.origin.url');
|
|
21
|
+
if (typeof remote !== 'string') throw new GitPanelError('session');
|
|
22
|
+
const trackingRemote = await config('branch.' + ref + '.remote');
|
|
23
|
+
const trackingRef = await config('branch.' + ref + '.merge');
|
|
24
|
+
if ((trackingRemote && trackingRemote !== 'origin') || (trackingRef && !trackingRef.startsWith('refs/heads/')))
|
|
25
|
+
throw new GitPanelError('unsupported');
|
|
26
|
+
const refspecs = await git.getConfigAll({...options, path: 'remote.origin.fetch'});
|
|
27
|
+
if (refspecs.some(spec => !/^\+?refs\/heads\/[^:\s]+:refs\/remotes\/origin\/[^:\s]+$/u.test(spec)))
|
|
28
|
+
throw new GitPanelError('unsupported');
|
|
29
|
+
if (operation === 'pull') {
|
|
30
|
+
if ((await git.statusMatrix({...options, refresh: false})).some(([, head, worktree, stage]) =>
|
|
31
|
+
head !== worktree || head !== stage)) throw new GitPanelError('pullUnsafe');
|
|
32
|
+
await git.walk({...options, trees: [git.STAGE()], map: async (_path, [entry]) => {
|
|
33
|
+
if (entry && await entry.type() !== 'tree' && await entry.mode() !== 0o100644)
|
|
34
|
+
throw new GitPanelError('unsupported');
|
|
35
|
+
}});
|
|
36
|
+
}
|
|
37
|
+
const write = operation === 'push';
|
|
38
|
+
const session = await getSession(remote, write);
|
|
39
|
+
if (!session) throw new GitPanelError('session');
|
|
40
|
+
guard();
|
|
41
|
+
const transport = createGitSmartHttp({remote, connection: session.connection,
|
|
42
|
+
getSession: () => getSession(remote, write), signal});
|
|
43
|
+
let receivedPushResponse = false;
|
|
44
|
+
let pushResponseFailed = false;
|
|
45
|
+
const http = {async request(request: Parameters<typeof transport.http.request>[0]) {
|
|
46
|
+
const response = await transport.http.request(request);
|
|
47
|
+
if (!write || request.method !== 'POST' || !response.body) return response;
|
|
48
|
+
receivedPushResponse = true;
|
|
49
|
+
const body = response.body;
|
|
50
|
+
// isomorphic-git stops at the report-status and drops later read errors, so record them here.
|
|
51
|
+
return {...response, body: (async function* () {
|
|
52
|
+
try {
|
|
53
|
+
yield* body;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
pushResponseFailed = true;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
})()};
|
|
59
|
+
}};
|
|
60
|
+
const args = {...options, ...transport, http, remote: 'origin', ref,
|
|
61
|
+
remoteRef: trackingRef || `refs/heads/${ref}`};
|
|
62
|
+
try {
|
|
63
|
+
if (operation === 'fetch') await git.fetch({...args, tags: false});
|
|
64
|
+
else if (operation === 'pull') await git.fastForward(args);
|
|
65
|
+
else {
|
|
66
|
+
await git.push({...args, force: false, signal});
|
|
67
|
+
if (pushResponseFailed) throw new GitPanelError('pushUnknown');
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof GitSmartHttpError) {
|
|
71
|
+
if (error.code === 'PUSH_OUTCOME_UNKNOWN') throw new GitPanelError('pushUnknown');
|
|
72
|
+
if (['SESSION_REQUIRED', 'SESSION_UNAVAILABLE', 'SESSION_EXPIRED', 'REAUTH_REQUIRED'].includes(error.code))
|
|
73
|
+
throw new GitPanelError('session');
|
|
74
|
+
}
|
|
75
|
+
const code = (error as {code?: string})?.code;
|
|
76
|
+
if (code === 'FastForwardError') throw new GitPanelError('diverged');
|
|
77
|
+
if (receivedPushResponse && !(error instanceof GitSmartHttpError) && code !== 'GitPushError')
|
|
78
|
+
throw new GitPanelError('pushUnknown');
|
|
79
|
+
if (error instanceof GitPanelError) throw error;
|
|
80
|
+
throw new GitPanelError('remoteFailed');
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await transaction.commit(guard);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (write) throw new GitPanelError('pushRefresh');
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
return globalThis.navigator?.locks ? navigator.locks.request('tw-plus-git-mutation', execute) : execute();
|
|
90
|
+
}
|