@kubohiroya/turbowarp-plus-editor-git 0.16.1 → 0.17.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 +14 -0
- package/README.md +14 -0
- package/dist/filesystem.d.ts +2 -2
- package/dist/github-pull-requests.d.ts +20 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +177 -14
- package/dist/local-operations.d.ts +8 -1
- package/dist/messages.d.ts +8 -0
- package/dist/{project-asset-paths-KV715791.js → project-asset-paths-nL5sV35A.js} +653 -1
- package/dist/{project-comparison-1rggIJse.js → project-comparison-DjMlwyp8.js} +10 -654
- package/dist/project-source-diff.js +2 -1
- package/dist/pull-request-panel.d.ts +25 -1
- package/dist/remote-operations.d.ts +23 -0
- package/dist/{repository-BYno-5f8.js → repository-xIv0iZkj.js} +346 -7
- package/dist/repository.d.ts +32 -0
- package/dist/repository.js +1 -1
- package/package.json +1 -1
- package/src/filesystem.ts +2 -1
- package/src/github-pull-requests.ts +7 -0
- package/src/index.tsx +31 -3
- package/src/local-operations.ts +23 -3
- package/src/messages.ts +8 -0
- package/src/pull-request-panel.tsx +125 -14
- package/src/remote-operations.ts +84 -3
- package/src/repository.ts +133 -1
- package/test/smart-http-fixture.js +20 -0
package/src/index.tsx
CHANGED
|
@@ -14,6 +14,7 @@ import {ConnectionSettings} from './connection-settings.js';
|
|
|
14
14
|
import type {GitConnectionSettings} from './connection-settings.js';
|
|
15
15
|
import type {RemoteOperation} from './remote-operations.js';
|
|
16
16
|
import type {GitAuthor, LocalOperation} from './local-operations.js';
|
|
17
|
+
import {pullRequestBranch} from './local-operations.js';
|
|
17
18
|
import {PullRequestReviewPanel} from './pull-request-panel.js';
|
|
18
19
|
import {openProjectSourceTabs} from './project-source-tabs.js';
|
|
19
20
|
import type {ProjectSourceTabsFile} from './project-source-tabs.js';
|
|
@@ -28,17 +29,18 @@ import type {LocatorVM} from './editor-block-locator.js';
|
|
|
28
29
|
export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
|
|
29
30
|
export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch-block-diff-renderer.js';
|
|
30
31
|
|
|
31
|
-
export {openRepository, createReadOnlyFs, GitPanelError} from './repository.js';
|
|
32
|
+
export {openRepository, createReadOnlyFs, GitPanelError, pullRequestRef} from './repository.js';
|
|
32
33
|
export {openProjectComparison} from './project-comparison.js';
|
|
33
34
|
export {openProjectSourceTabs} from './project-source-tabs.js';
|
|
34
35
|
export type {ProjectSourceTabsFile, ProjectSourceTabsOptions} from './project-source-tabs.js';
|
|
35
36
|
export type {ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
|
|
36
|
-
export type {Repository, Snapshot, FileChange, FileDiff, HistoryCommit, HistoryCursor, HistoryPage, HistoryRef} from './repository.js';
|
|
37
|
+
export type {PullRequestBase, PullRequestFetch, PullRequestFiles, Repository, Snapshot, FileChange, FileDiff, HistoryCommit, HistoryCursor, HistoryPage, HistoryRef} from './repository.js';
|
|
37
38
|
export {HistoryGraph, laneColors} from './history-graph-view.js';
|
|
38
39
|
export type {HistoryActionResult, HistoryGraphProps} from './history-graph-view.js';
|
|
39
40
|
export {layoutHistory} from './history-graph.js';
|
|
40
41
|
export type {GraphCommit, GraphLayout, GraphRow, GraphSegment} from './history-graph.js';
|
|
41
42
|
export type {GitAuthor, LocalOperation} from './local-operations.js';
|
|
43
|
+
export {pullRequestBranch} from './local-operations.js';
|
|
42
44
|
|
|
43
45
|
export interface GitHost {
|
|
44
46
|
connectionSettings?: GitConnectionSettings;
|
|
@@ -365,6 +367,12 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
365
367
|
notice: operation === 'pull' && current.current.project !== null ? labels.pulledProject : labels.remoteDone};
|
|
366
368
|
});
|
|
367
369
|
};
|
|
370
|
+
// A connected GitHub connection reads pull request contents over Git Smart HTTP, so private repositories work too.
|
|
371
|
+
const pullRequestConnection = useMemo(() => {
|
|
372
|
+
const settings = host.connectionSettings;
|
|
373
|
+
const getSession = settings?.getSession;
|
|
374
|
+
return getSession ? {getSession, connected: () => settings.getView().state.status === 'connected'} : undefined;
|
|
375
|
+
}, [host]);
|
|
368
376
|
const [message, setMessage] = useState('');
|
|
369
377
|
const localOperation = (operation: LocalOperation, notice?: string) => {
|
|
370
378
|
const repository = current.current.repository;
|
|
@@ -401,6 +409,25 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
401
409
|
if (operation.kind === 'createBranch' && !operation.fromRemote && !at) setNewBranch('');
|
|
402
410
|
return result;
|
|
403
411
|
};
|
|
412
|
+
/**
|
|
413
|
+
* Opens a pull request version fetched over the connection: creates `pr/<number>` at its head (or reuses that
|
|
414
|
+
* branch when it already points there), switches to it with the same refusals as any switch, and opens the project
|
|
415
|
+
* folder again from it. The branch the person was on stays, so switching back is an ordinary switch.
|
|
416
|
+
*/
|
|
417
|
+
const openPullRequest = async (number: number, head: string): Promise<HistoryActionResult> => {
|
|
418
|
+
const repository = current.current.repository;
|
|
419
|
+
if (!repository) return {done: false};
|
|
420
|
+
const original = current.current.snapshot?.branch ?? null;
|
|
421
|
+
const name = pullRequestBranch(number);
|
|
422
|
+
const result = await changeBranch({kind: 'createBranch', name, at: head, checkout: true, reuse: true});
|
|
423
|
+
if (!result.done) return result;
|
|
424
|
+
const project = current.current.project;
|
|
425
|
+
if (project !== null && await repository.hasFolder(name, project)) await openProject(project);
|
|
426
|
+
const notice = (original && original !== name ? labels.openedPullRequest : labels.openedPullRequestDetached)
|
|
427
|
+
.replace('{number}', String(number)).replace('{branch}', name).replace('{previous}', original ?? '');
|
|
428
|
+
if (alive.current) setState(previous => previous.error ? previous : {...previous, notice});
|
|
429
|
+
return result;
|
|
430
|
+
};
|
|
404
431
|
// The history graph reads its own pages after each snapshot, through the same queue as the other reads.
|
|
405
432
|
const [history, setHistory] = useState<{commits: HistoryCommit[]; cursor: HistoryCursor | null; loading: boolean; failed: boolean}>(
|
|
406
433
|
{commits: [], cursor: null, loading: false, failed: false});
|
|
@@ -517,7 +544,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
517
544
|
{!state.repository && <p>{labels.empty}</p>}
|
|
518
545
|
{review && state.repository && host.renderReviewBlock &&
|
|
519
546
|
<PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}
|
|
520
|
-
locateFor={reviewLocator} openTabs={openTabs}
|
|
547
|
+
locateFor={reviewLocator} openTabs={openTabs} connection={pullRequestConnection}
|
|
548
|
+
openLocally={branching ? {open: openPullRequest, busy: state.busy} : undefined}/>}
|
|
521
549
|
{!review && state.repository && <div className="twgit-content">
|
|
522
550
|
<aside>
|
|
523
551
|
{state.browser && <details>
|
package/src/local-operations.ts
CHANGED
|
@@ -10,11 +10,18 @@ export type LocalOperation =
|
|
|
10
10
|
| {kind: 'commit'; message: string; author?: GitAuthor}
|
|
11
11
|
/**
|
|
12
12
|
* Creates `name` at HEAD, at the commit `at`, or at `origin/<name>` with `fromRemote`, and switches to it with
|
|
13
|
-
* `checkout`.
|
|
13
|
+
* `checkout`. With `reuse` (only with `at`), a branch `name` that already points at `at` is switched to instead of
|
|
14
|
+
* failing, and one that points elsewhere fails with `pullRequestBranch` and is never moved.
|
|
14
15
|
*/
|
|
15
|
-
| {kind: 'createBranch'; name: string; fromRemote?: boolean; at?: string; checkout: boolean}
|
|
16
|
+
| {kind: 'createBranch'; name: string; fromRemote?: boolean; at?: string; checkout: boolean; reuse?: boolean}
|
|
16
17
|
| {kind: 'switchBranch'; name: string};
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The local branch a pull request is opened on: `pr/<number>`, a name `git check-ref-format --branch` accepts. It is
|
|
21
|
+
* created at the fetched head (`pullRequestRef(number)`) and is an ordinary branch afterwards.
|
|
22
|
+
*/
|
|
23
|
+
export const pullRequestBranch = (number: number) => `pr/${number}`;
|
|
24
|
+
|
|
18
25
|
const missing = (error: unknown) => ['ENOENT', 'ENOTDIR', 'NotFoundError'].includes(errorCode(error) ?? '');
|
|
19
26
|
|
|
20
27
|
/**
|
|
@@ -53,6 +60,7 @@ export async function runLocalOperation(root: FileSystemDirectoryHandle, operati
|
|
|
53
60
|
}
|
|
54
61
|
if (operation.kind === 'createBranch') {
|
|
55
62
|
let object: string;
|
|
63
|
+
if (operation.reuse && operation.at === undefined) throw new GitPanelError('invalid');
|
|
56
64
|
if (operation.at !== undefined) {
|
|
57
65
|
if (operation.fromRemote || !/^[0-9a-f]{40}$/.test(operation.at)) throw new GitPanelError('invalid');
|
|
58
66
|
object = await git.readCommit({...options, oid: operation.at})
|
|
@@ -66,7 +74,19 @@ export async function runLocalOperation(root: FileSystemDirectoryHandle, operati
|
|
|
66
74
|
} else {
|
|
67
75
|
object = await git.resolveRef({...options, ref: 'HEAD'}).catch(() => {throw new GitPanelError('unsupported');});
|
|
68
76
|
}
|
|
69
|
-
|
|
77
|
+
let existing: string | undefined;
|
|
78
|
+
if (operation.reuse && (await git.listBranches(options)).includes(name)) {
|
|
79
|
+
existing = await git.resolveRef({...options, ref: local});
|
|
80
|
+
// Never move a branch the person may have recorded on: only the same commit is reused.
|
|
81
|
+
if (existing !== object) throw new GitPanelError('pullRequestBranch');
|
|
82
|
+
}
|
|
83
|
+
if (existing === undefined) {
|
|
84
|
+
// Git keeps branches as files under refs/heads, so `pr` and `pr/7` cannot both exist.
|
|
85
|
+
const conflict = (await git.listBranches(options)).some(branch =>
|
|
86
|
+
branch !== name && (name.startsWith(`${branch}/`) || branch.startsWith(`${name}/`)));
|
|
87
|
+
if (conflict) throw new GitPanelError('branchConflict');
|
|
88
|
+
}
|
|
89
|
+
if (existing === undefined) try {
|
|
70
90
|
// The ref first: isomorphic-git's checkout of a missing branch would write tracking config.
|
|
71
91
|
await git.branch({...options, ref: name, object, checkout: false});
|
|
72
92
|
} catch (error) {
|
package/src/messages.ts
CHANGED
|
@@ -20,6 +20,10 @@ export const messages = {
|
|
|
20
20
|
commitMessage: 'Write a message for this record.',
|
|
21
21
|
switchUnsafe: 'Stop the project, and save or record (or undo) every change, before switching branches.',
|
|
22
22
|
branchExists: 'A branch with this name already exists.',
|
|
23
|
+
branchConflict: 'This name clashes with an existing branch: Git cannot keep both a branch and another branch under its name followed by /, such as pr and pr/7. Rename or delete the other branch with Git, or choose another name.',
|
|
24
|
+
pullRequestBranch: 'The branch pr/<number> already exists with other records, so this version was not opened. The branch is left unchanged. Switch to it in Local changes, or rename or delete it with Git, then open the pull request here again.',
|
|
25
|
+
openedPullRequest: 'Opened pull request #{number} on the local branch {branch}. To go back, switch to {previous} in Local changes.',
|
|
26
|
+
openedPullRequestDetached: 'Opened pull request #{number} on the local branch {branch}. To go back, switch to another branch in Local changes.',
|
|
23
27
|
locked: 'Another Git tool is working on this repository (a lock file, or a merge, cherry-pick, revert or rebase in progress). Try again after it finishes.',
|
|
24
28
|
branchName: 'Git cannot use this name for a branch.',
|
|
25
29
|
pullUnsafe: 'Stop the project, and save or undo unsaved edits and unrecorded changes, before getting changes from GitHub (pull).',
|
|
@@ -73,6 +77,10 @@ export const messages = {
|
|
|
73
77
|
commitMessage: 'この記録の説明を書いてください。',
|
|
74
78
|
switchUnsafe: 'ブランチを切り替える前に、実行を止め、すべての変更を保存して記録(または取り消し)してください。',
|
|
75
79
|
branchExists: '同じ名前のブランチがすでにあります。',
|
|
80
|
+
branchConflict: 'この名前は既存のブランチとぶつかります。Git では、pr と pr/7 のように、あるブランチと、その名前に / を続けたブランチを両方は持てません。Git でもう一方の名前を変えるか消すか、別の名前にしてください。',
|
|
81
|
+
pullRequestBranch: 'ブランチ pr/<番号> が、別の記録を指してすでにあるため、この版を開きませんでした。ブランチはそのままです。「ローカルの変更」で切り替えるか、Git で名前を変えるか消してから、もう一度ここで開いてください。',
|
|
82
|
+
openedPullRequest: 'プルリクエスト #{number} を、ローカルブランチ {branch} で開きました。戻るときは「ローカルの変更」で {previous} に切り替えてください。',
|
|
83
|
+
openedPullRequestDetached: 'プルリクエスト #{number} を、ローカルブランチ {branch} で開きました。戻るときは「ローカルの変更」で別のブランチに切り替えてください。',
|
|
76
84
|
locked: 'ほかの Git の操作が、このリポジトリを使っています(ロックファイル、またはマージ・cherry-pick・revert・rebase の途中)。終わってから、もう一度試してください。',
|
|
77
85
|
branchName: 'この名前は Git のブランチ名に使えません。',
|
|
78
86
|
pullUnsafe: 'GitHub から取り込む前に、実行を止め、保存していない編集とまだ記録していない変更をなくしてください。',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, {useEffect, useMemo, useState} from 'react';
|
|
1
|
+
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
|
2
2
|
import {createGitHubPullRequests, gitHubRepositoryFromOrigin, PullRequestError} from './github-pull-requests.js';
|
|
3
3
|
import type {GitHubPullRequests} from './github-pull-requests.js';
|
|
4
4
|
import {ProjectSourceReview} from './project-source-review.js';
|
|
@@ -7,6 +7,10 @@ import type {Repository} from './repository.js';
|
|
|
7
7
|
import type {ProjectBlockLocator} from './block-diff-location.js';
|
|
8
8
|
import {readProjectSourceDiff} from './project-source-patch.js';
|
|
9
9
|
import type {ProjectSourceTabsFile} from './project-source-tabs.js';
|
|
10
|
+
import {GitPanelError} from './filesystem.js';
|
|
11
|
+
import type {GetRemoteSession} from './remote-operations.js';
|
|
12
|
+
import type {ProjectSourceReviewFile} from './project-source-review.js';
|
|
13
|
+
import {pullRequestBranch} from './local-operations.js';
|
|
10
14
|
|
|
11
15
|
type OpenTabs = (key: string, files: readonly ProjectSourceTabsFile[], rememberAs?: string) => number;
|
|
12
16
|
|
|
@@ -20,7 +24,19 @@ const en = {
|
|
|
20
24
|
rateLimit: 'The public GitHub API rate limit was reached. Try again after the limit resets.',
|
|
21
25
|
failed: 'Could not read GitHub. Try again.', changed: 'The pull request changed while it was being read. Open it again.',
|
|
22
26
|
tooManyFiles: 'GitHub only returns the first 3,000 changed files. This pull request exceeds that limit.',
|
|
23
|
-
openTabs: 'Open as tabs'
|
|
27
|
+
openTabs: 'Open as tabs',
|
|
28
|
+
session: 'Connect to GitHub again (GitHub connection) to read this pull request.',
|
|
29
|
+
forbidden: 'The GitHub connection is not allowed to read this repository.',
|
|
30
|
+
pullRequestNotFound: 'This repository has no pull request with this number.',
|
|
31
|
+
network: 'Could not reach GitHub through the connection. Check the network and try again.',
|
|
32
|
+
remoteFailed: 'Could not read the pull request through the GitHub connection. Try again.',
|
|
33
|
+
baseMissing: 'The base branch is not on GitHub. Choose another base branch.',
|
|
34
|
+
viaConnection: 'Read through the GitHub connection.',
|
|
35
|
+
listPrivate: 'The list uses the public GitHub API. Enter a pull request number to read it through the connection.',
|
|
36
|
+
noMetadata: 'The title and base branch are not available without the public GitHub API. Choose the base branch to compare with.',
|
|
37
|
+
base: 'Base branch', pullRequest: 'Pull request',
|
|
38
|
+
openLocally: 'Open here as {branch}',
|
|
39
|
+
openLocallyHint: 'Creates the local branch {branch} at this version, switches to it and opens the project, so it can be run. Unsaved edits and unrecorded changes stop it. Switch back in Local changes.'
|
|
24
40
|
};
|
|
25
41
|
const ja: typeof en = {
|
|
26
42
|
title: 'プルリクエスト', number: 'プルリクエスト番号', openNumber: 'PR を開く',
|
|
@@ -33,33 +49,76 @@ const ja: typeof en = {
|
|
|
33
49
|
failed: 'GitHub から取得できませんでした。再試行してください。',
|
|
34
50
|
changed: '取得中にプルリクエストが更新されました。もう一度開いてください。',
|
|
35
51
|
tooManyFiles: '変更ファイルが GitHub API の取得上限である 3,000 件を超えています。',
|
|
36
|
-
openTabs: 'タブで開く'
|
|
52
|
+
openTabs: 'タブで開く',
|
|
53
|
+
session: 'この PR を読むには、GitHub 接続でもう一度接続してください。',
|
|
54
|
+
forbidden: 'GitHub 接続に、このリポジトリを読む権限がありません。',
|
|
55
|
+
pullRequestNotFound: 'このリポジトリに、この番号のプルリクエストはありません。',
|
|
56
|
+
network: 'GitHub 接続で GitHub に届きませんでした。ネットワークを確認して、もう一度試してください。',
|
|
57
|
+
remoteFailed: 'GitHub 接続でプルリクエストを読めませんでした。もう一度試してください。',
|
|
58
|
+
baseMissing: 'ベースのブランチが GitHub にありません。別のベースを選んでください。',
|
|
59
|
+
viaConnection: 'GitHub 接続で取得しました。',
|
|
60
|
+
listPrivate: '一覧は公開 GitHub API を使います。番号を入れると、GitHub 接続で読みます。',
|
|
61
|
+
noMetadata: '公開 GitHub API を使えないため、題名とベースのブランチがわかりません。比べるベースのブランチを選んでください。',
|
|
62
|
+
base: 'ベースのブランチ', pullRequest: 'プルリクエスト',
|
|
63
|
+
openLocally: '{branch} として手元で開く',
|
|
64
|
+
openLocallyHint: 'この版にローカルブランチ {branch} を作って切り替え、作品を開き直して実行できるようにします。保存していない編集や記録していない変更があるときは行いません。元に戻すときは「ローカルの変更」で切り替えてください。'
|
|
37
65
|
};
|
|
38
66
|
|
|
67
|
+
const gitErrors = ['session', 'forbidden', 'pullRequestNotFound', 'network', 'remoteFailed', 'baseMissing', 'changed'] as const;
|
|
39
68
|
function errorMessage(error: unknown, labels: typeof en) {
|
|
40
|
-
|
|
69
|
+
if (error instanceof PullRequestError) return labels[error.code];
|
|
70
|
+
if (error instanceof GitPanelError) {
|
|
71
|
+
const code = gitErrors.find(code => code === error.code);
|
|
72
|
+
return code ? labels[code] : labels.failed;
|
|
73
|
+
}
|
|
74
|
+
return labels.failed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Opens a pull request version fetched over the connection in the work tree: the host creates (or reuses) the local
|
|
79
|
+
* branch `pullRequestBranch(number)` at `head` and switches to it. `busy` disables the button.
|
|
80
|
+
*/
|
|
81
|
+
export interface PullRequestOpenLocally {
|
|
82
|
+
open(number: number, head: string): unknown;
|
|
83
|
+
busy: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The person's GitHub connection: while it is connected, pull request contents are read over Git Smart HTTP. */
|
|
87
|
+
export interface PullRequestConnection {
|
|
88
|
+
getSession: GetRemoteSession;
|
|
89
|
+
connected(): boolean;
|
|
41
90
|
}
|
|
42
91
|
|
|
43
92
|
type Result<T> = {data: T; error?: never} | {data?: never; error: string};
|
|
44
93
|
type Listing = Awaited<ReturnType<GitHubPullRequests['list']>>;
|
|
45
|
-
type
|
|
94
|
+
type Metadata = Awaited<ReturnType<GitHubPullRequests['metadata']>>;
|
|
95
|
+
/** A pull request to show: from the public API, or from Git objects fetched over the connection. */
|
|
96
|
+
type Pull = {
|
|
97
|
+
number: number; title: string; description: string; author?: string; state?: Metadata['state'];
|
|
98
|
+
head: {sha: string; ref: string}; base: {ref: string | null; sha: string};
|
|
99
|
+
files: readonly ProjectSourceReviewFile[]; url: string;
|
|
100
|
+
/** Set when the contents came over the connection: the branches to compare with, when the API gave no base. */
|
|
101
|
+
git?: {branches: readonly string[] | null};
|
|
102
|
+
};
|
|
46
103
|
|
|
47
|
-
function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}: {
|
|
104
|
+
function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs, repository, connection, openLocally}: {
|
|
48
105
|
name: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
|
|
49
106
|
locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
|
|
50
|
-
openTabs?: OpenTabs;
|
|
107
|
+
openTabs?: OpenTabs; repository: Repository; connection?: PullRequestConnection; openLocally?: PullRequestOpenLocally;
|
|
51
108
|
}) {
|
|
52
109
|
const client = useMemo(() => createGitHubPullRequests(name), [name]);
|
|
53
110
|
const [page, setPage] = useState(1);
|
|
54
111
|
const [listing, setListing] = useState<{page: number; result: Result<Listing>}>();
|
|
55
112
|
const [input, setInput] = useState('');
|
|
56
|
-
const [selection, setSelection] = useState<{number: number}>();
|
|
113
|
+
const [selection, setSelection] = useState<{number: number; base?: string}>();
|
|
57
114
|
const [loaded, setLoaded] = useState<{selection: NonNullable<typeof selection>; result: Result<Pull>}>();
|
|
58
115
|
const list = listing?.page === page ? listing.result : undefined;
|
|
116
|
+
const [listConnected, setListConnected] = useState(false);
|
|
59
117
|
const detail = loaded?.selection === selection ? loaded?.result : undefined;
|
|
60
118
|
useEffect(() => {
|
|
61
119
|
const controller = new AbortController();
|
|
62
120
|
setListing(undefined);
|
|
121
|
+
setListConnected(!!connection?.connected());
|
|
63
122
|
void client.list(page, controller.signal).then(data => {
|
|
64
123
|
if (!controller.signal.aborted) setListing({page, result: {data}});
|
|
65
124
|
}, error => {
|
|
@@ -70,13 +129,44 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}:
|
|
|
70
129
|
useEffect(() => {
|
|
71
130
|
if (!selection) return;
|
|
72
131
|
const controller = new AbortController();
|
|
73
|
-
|
|
132
|
+
const load = (): Promise<Pull> => connection?.connected() ?
|
|
133
|
+
loadThroughConnection(selection.number, selection.base, controller.signal) : client.load(selection.number, controller.signal);
|
|
134
|
+
void load().then(data => {
|
|
74
135
|
if (!controller.signal.aborted) setLoaded({selection, result: {data}});
|
|
75
136
|
}, error => {
|
|
76
137
|
if (!controller.signal.aborted) setLoaded({selection, result: {error: errorMessage(error, labels)}});
|
|
77
138
|
});
|
|
78
139
|
return () => controller.abort();
|
|
79
140
|
}, [client, selection, labels]);
|
|
141
|
+
const fetched = useRef<{number: number; head: string; branches: readonly string[]; defaultBranch: string | null}>();
|
|
142
|
+
/**
|
|
143
|
+
* Fetches `refs/pull/<number>/head` and the branches over Git Smart HTTP and compares them from local objects. The
|
|
144
|
+
* public API only adds the title, description and base when it can read the repository; the contents never come
|
|
145
|
+
* from it, so a private repository is read only through the connection.
|
|
146
|
+
*/
|
|
147
|
+
const loadThroughConnection = async (number: number, chosenBase: string | undefined, signal: AbortSignal): Promise<Pull> => {
|
|
148
|
+
const guard = () => {if (signal.aborted) throw new GitPanelError('changed');};
|
|
149
|
+
let head: string, branches: readonly string[], defaultBranch: string | null, metadata: Metadata | null = null;
|
|
150
|
+
if (chosenBase !== undefined && fetched.current?.number === number) {
|
|
151
|
+
({head, branches, defaultBranch} = fetched.current);
|
|
152
|
+
} else {
|
|
153
|
+
metadata = await client.metadata(number, signal).catch(error => {
|
|
154
|
+
if (signal.aborted) throw error;
|
|
155
|
+
return null;
|
|
156
|
+
});
|
|
157
|
+
const result = await repository.fetchPullRequest(number, connection!.getSession, guard, signal);
|
|
158
|
+
if (metadata && metadata.head.sha !== result.head) throw new PullRequestError('changed');
|
|
159
|
+
({head, branches, defaultBranch} = result);
|
|
160
|
+
fetched.current = {number, head, branches, defaultBranch};
|
|
161
|
+
}
|
|
162
|
+
const branch = chosenBase ?? metadata?.base.ref ?? defaultBranch ?? branches[0];
|
|
163
|
+
const read = await repository.readPullRequest(head, {sha: metadata?.base.sha, branch});
|
|
164
|
+
guard();
|
|
165
|
+
return {number, title: metadata?.title ?? `${labels.pullRequest} #${number}`, description: metadata?.description ?? '',
|
|
166
|
+
author: metadata?.author, state: metadata?.state, head: {sha: head, ref: metadata?.head.ref ?? `pull/${number}/head`},
|
|
167
|
+
base: read.base, files: read.files, url: 'https://github.com/' + name + '/pull/' + number,
|
|
168
|
+
git: {branches: metadata ? null : branches}};
|
|
169
|
+
};
|
|
80
170
|
const choose = (number: number) => {
|
|
81
171
|
if (!Number.isSafeInteger(number) || number < 1) return;
|
|
82
172
|
setSelection({number});
|
|
@@ -93,7 +183,8 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}:
|
|
|
93
183
|
<button type="submit">{labels.openNumber}</button>
|
|
94
184
|
</form>
|
|
95
185
|
{!list && <p role="status">{labels.loading}</p>}
|
|
96
|
-
{list?.error &&
|
|
186
|
+
{list?.error && (listConnected && list.error === labels.unavailable ?
|
|
187
|
+
<p role="status">{labels.listPrivate}</p> : <p role="alert">{list.error}</p>)}
|
|
97
188
|
{list?.data && <>
|
|
98
189
|
{!list.data.items.length && <p>{labels.empty}</p>}
|
|
99
190
|
<ul>{list.data.items.map(item => <li key={item.number}>
|
|
@@ -112,9 +203,13 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}:
|
|
|
112
203
|
{detail?.error && <p role="alert">{detail.error}</p>}
|
|
113
204
|
{pr && <>
|
|
114
205
|
<p className="twgit-pr-revision">
|
|
115
|
-
<strong>#{pr.number} · {labels[pr.state]}</strong>
|
|
206
|
+
<strong>#{pr.number}{pr.state && <> · {labels[pr.state]}</>}</strong>
|
|
116
207
|
{pr.author && <span>{pr.author}</span>}
|
|
117
|
-
<code title={pr.head.sha}>{pr.head.ref} → {pr.base.ref}</code>
|
|
208
|
+
<code title={pr.head.sha}>{pr.head.ref} → {pr.base.ref ?? pr.base.sha.slice(0, 12)}</code>
|
|
209
|
+
{pr.git?.branches && <label>{labels.base} <select data-git-pr-base="" value={pr.base.ref ?? ''}
|
|
210
|
+
onChange={event => setSelection({number: pr.number, base: event.currentTarget.value})}>
|
|
211
|
+
{pr.git.branches.map(branch => <option key={branch} value={branch}>{branch}</option>)}
|
|
212
|
+
</select></label>}
|
|
118
213
|
<a href={pr.url} target="_blank" rel="noreferrer">{labels.onGitHub}</a>
|
|
119
214
|
{openTabs && <button type="button" data-git-open-tabs="" onClick={() => openTabs(`${name}#${pr.number}@${pr.head.sha}`,
|
|
120
215
|
pr.files.flatMap(file => {
|
|
@@ -128,7 +223,12 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}:
|
|
|
128
223
|
return projects ? [{path: file.path, before: projects[0], after: projects[1], loadAsset: file.loadAsset,
|
|
129
224
|
locateBlock: locateFor?.(file.path, pr.head.sha)}] : [];
|
|
130
225
|
}), `${name}#${pr.number}`)}>{labels.openTabs}</button>}
|
|
226
|
+
{pr.git && openLocally && <button type="button" data-git-open-pr-locally="" disabled={openLocally.busy}
|
|
227
|
+
title={labels.openLocallyHint.replace('{branch}', pullRequestBranch(pr.number))}
|
|
228
|
+
onClick={() => void openLocally.open(pr.number, pr.head.sha)}>
|
|
229
|
+
{labels.openLocally.replace('{branch}', pullRequestBranch(pr.number))}</button>}
|
|
131
230
|
</p>
|
|
231
|
+
{pr.git && <p className="twgit-context" role="status">{labels.viaConnection}{pr.git.branches && <> {labels.noMetadata}</>}</p>}
|
|
132
232
|
<ProjectSourceReview title={pr.title} description={pr.description} files={locateFor ? pr.files.map(file => 'patch' in file ? {...file, locateBlock: locateFor(file.path, pr.head.sha)} : file) : pr.files}
|
|
133
233
|
renderBlock={renderBlock} locale={locale} rememberAs={`${name}#${pr.number}`}/>
|
|
134
234
|
</>}
|
|
@@ -136,8 +236,18 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}:
|
|
|
136
236
|
</div>;
|
|
137
237
|
}
|
|
138
238
|
|
|
139
|
-
export function PullRequestReviewPanel({repository, locale, renderBlock, locateFor, openTabs}: {
|
|
239
|
+
export function PullRequestReviewPanel({repository, locale, renderBlock, locateFor, openTabs, connection, openLocally}: {
|
|
140
240
|
repository: Repository; locale: string; renderBlock: ProjectBlockRenderer;
|
|
241
|
+
/**
|
|
242
|
+
* While connected, pull request contents are fetched over Git Smart HTTP (`refs/pull/<number>/head`) and compared
|
|
243
|
+
* from local Git objects, so private repositories can be reviewed. Without it, the public GitHub API is read.
|
|
244
|
+
*/
|
|
245
|
+
connection?: PullRequestConnection;
|
|
246
|
+
/**
|
|
247
|
+
* Offers to open a pull request read over the connection in the work tree, on the branch `pr/<number>`. Pull
|
|
248
|
+
* requests read from the public API have no local objects, so the button is not shown for them.
|
|
249
|
+
*/
|
|
250
|
+
openLocally?: PullRequestOpenLocally;
|
|
141
251
|
/** Opens a pull request's project changes as tabs of the host's layout. */
|
|
142
252
|
openTabs?: OpenTabs;
|
|
143
253
|
/** Returns a locator for a project file whose PR head matches the open project, or `undefined`. */
|
|
@@ -156,5 +266,6 @@ export function PullRequestReviewPanel({repository, locale, renderBlock, locateF
|
|
|
156
266
|
}, [repository]);
|
|
157
267
|
if (origin?.repository !== repository) return <p role="status" className="twgit-context">{labels.loading}</p>;
|
|
158
268
|
if (!origin.name) return <p className="twgit-context" role="status">{origin.failed ? labels.failed : labels.noOrigin}</p>;
|
|
159
|
-
return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor} openTabs={openTabs}
|
|
269
|
+
return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor} openTabs={openTabs}
|
|
270
|
+
repository={repository} connection={connection} openLocally={openLocally}/>;
|
|
160
271
|
}
|
package/src/remote-operations.ts
CHANGED
|
@@ -8,6 +8,89 @@ export type RemoteOperation = 'fetch' | 'pull' | 'push';
|
|
|
8
8
|
export type RemoteSession = NonNullable<Awaited<ReturnType<GitSmartHttpOptions['getSession']>>>;
|
|
9
9
|
export type GetRemoteSession = (remote: string, write: boolean) => Promise<RemoteSession | null>;
|
|
10
10
|
|
|
11
|
+
type GitOptions = {fs: ReturnType<typeof createMutationFs>['fs']; dir: string; gitdir: string; cache: object};
|
|
12
|
+
const sessionCodes = ['SESSION_REQUIRED', 'SESSION_UNAVAILABLE', 'SESSION_EXPIRED', 'REAUTH_REQUIRED', 'INVALID_SESSION'];
|
|
13
|
+
|
|
14
|
+
// Fetch writes remote-tracking refs through these refspecs; anything else could land a ref outside refs/remotes/origin.
|
|
15
|
+
async function checkRefspecs(options: GitOptions) {
|
|
16
|
+
const refspecs = await git.getConfigAll({...options, path: 'remote.origin.fetch'});
|
|
17
|
+
if (refspecs.some(spec => !/^\+?refs\/heads\/[^:\s]+:refs\/remotes\/origin\/[^:\s]+$/u.test(spec)))
|
|
18
|
+
throw new GitPanelError('unsupported');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where a fetched pull request head is kept: outside `refs/heads`, `refs/remotes` and `refs/tags`, so Git CLI lists it
|
|
23
|
+
* with `git for-each-ref refs/pull` and `git log --all` but not as a branch or tag, `git fetch --prune` leaves it alone,
|
|
24
|
+
* and `git gc` keeps its objects. Each fetch of the same pull request replaces it.
|
|
25
|
+
*/
|
|
26
|
+
export const pullRequestRef = (number: number) => `refs/pull/origin/${number}/head`;
|
|
27
|
+
|
|
28
|
+
export interface PullRequestFetch {
|
|
29
|
+
readonly number: number;
|
|
30
|
+
/** The commit at GitHub's `refs/pull/<number>/head`, kept locally at `ref`. */
|
|
31
|
+
readonly head: string;
|
|
32
|
+
readonly ref: string;
|
|
33
|
+
/** GitHub's default branch, from the advertised `HEAD`, or `null` when it is not advertised. */
|
|
34
|
+
readonly defaultBranch: string | null;
|
|
35
|
+
/** The branches on GitHub, as `refs/remotes/origin/<name>` after this fetch. */
|
|
36
|
+
readonly branches: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fetches GitHub's `refs/pull/<number>/head` together with every branch, over the connection's Git Smart HTTP, like
|
|
41
|
+
* `git fetch origin refs/pull/<number>/head`. The branches update `refs/remotes/origin/*` as fetch does, and the head
|
|
42
|
+
* is kept at `pullRequestRef(number)`. The work tree, the index, HEAD and `.git/config` are never written. The writes
|
|
43
|
+
* are staged first and applied together, under the same lock as fetch, pull and push.
|
|
44
|
+
*/
|
|
45
|
+
export async function runPullRequestFetch(root: FileSystemDirectoryHandle, number: number,
|
|
46
|
+
getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal): Promise<PullRequestFetch> {
|
|
47
|
+
if (!Number.isSafeInteger(number) || number < 1) throw new GitPanelError('pullRequestNotFound');
|
|
48
|
+
const execute = async () => {
|
|
49
|
+
guard();
|
|
50
|
+
const transaction = createMutationFs(root, false);
|
|
51
|
+
const options = {fs: transaction.fs, dir: '/', gitdir: '/.git', cache: {}};
|
|
52
|
+
const remote = await git.getConfig({...options, path: 'remote.origin.url'});
|
|
53
|
+
if (typeof remote !== 'string') throw new GitPanelError('session');
|
|
54
|
+
await checkRefspecs(options);
|
|
55
|
+
const session = await getSession(remote, false);
|
|
56
|
+
if (!session) throw new GitPanelError('session');
|
|
57
|
+
guard();
|
|
58
|
+
const transport = createGitSmartHttp({remote, connection: session.connection,
|
|
59
|
+
getSession: () => getSession(remote, false), signal});
|
|
60
|
+
const source = `refs/pull/${number}/head`;
|
|
61
|
+
let result: Awaited<ReturnType<typeof git.fetch>>;
|
|
62
|
+
try {
|
|
63
|
+
result = await git.fetch({...options, ...transport, remote: 'origin', remoteRef: source, tags: false});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const {code, data} = error as {code?: string; data?: {what?: string}};
|
|
66
|
+
// isomorphic-git resolves the wanted ref against the advertisement before it sends any request for objects.
|
|
67
|
+
if (code === 'NotFoundError' && data?.what === source) throw new GitPanelError('pullRequestNotFound');
|
|
68
|
+
throw remoteError(error);
|
|
69
|
+
}
|
|
70
|
+
const head = result.fetchHead;
|
|
71
|
+
if (!head || !/^[0-9a-f]{40}$/u.test(head)) throw new GitPanelError('remoteFailed');
|
|
72
|
+
const ref = pullRequestRef(number);
|
|
73
|
+
await git.writeRef({...options, ref, value: head, force: true});
|
|
74
|
+
const branches = (await git.listBranches({...options, remote: 'origin'})).filter(name => name !== 'HEAD').sort();
|
|
75
|
+
const advertised = result.defaultBranch?.startsWith('refs/heads/') ? result.defaultBranch.slice(11) : null;
|
|
76
|
+
await transaction.commit(guard);
|
|
77
|
+
return {number, head, ref, defaultBranch: advertised && branches.includes(advertised) ? advertised : null, branches};
|
|
78
|
+
};
|
|
79
|
+
return globalThis.navigator?.locks ? navigator.locks.request('tw-plus-git-mutation', execute) : execute();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Maps a failed read from GitHub to what the person can act on: the connection, access, or the network. */
|
|
83
|
+
function remoteError(error: unknown) {
|
|
84
|
+
if (error instanceof GitPanelError) return error;
|
|
85
|
+
if (error instanceof GitSmartHttpError) {
|
|
86
|
+
if (sessionCodes.includes(error.code)) return new GitPanelError('session');
|
|
87
|
+
if (error.code === 'FORBIDDEN') return new GitPanelError('forbidden');
|
|
88
|
+
if (['NETWORK_ERROR', 'UPSTREAM_FAILURE', 'UPSTREAM_TIMEOUT', 'RATE_LIMITED'].includes(error.code))
|
|
89
|
+
return new GitPanelError('network');
|
|
90
|
+
}
|
|
91
|
+
return new GitPanelError('remoteFailed');
|
|
92
|
+
}
|
|
93
|
+
|
|
11
94
|
export async function runRemoteOperation(root: FileSystemDirectoryHandle, operation: RemoteOperation,
|
|
12
95
|
getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) {
|
|
13
96
|
const execute = async () => {
|
|
@@ -23,9 +106,7 @@ export async function runRemoteOperation(root: FileSystemDirectoryHandle, operat
|
|
|
23
106
|
const trackingRef = await config('branch.' + ref + '.merge');
|
|
24
107
|
if ((trackingRemote && trackingRemote !== 'origin') || (trackingRef && !trackingRef.startsWith('refs/heads/')))
|
|
25
108
|
throw new GitPanelError('unsupported');
|
|
26
|
-
|
|
27
|
-
if (refspecs.some(spec => !/^\+?refs\/heads\/[^:\s]+:refs\/remotes\/origin\/[^:\s]+$/u.test(spec)))
|
|
28
|
-
throw new GitPanelError('unsupported');
|
|
109
|
+
await checkRefspecs(options);
|
|
29
110
|
if (operation === 'pull') {
|
|
30
111
|
if ((await git.statusMatrix({...options, refresh: false})).some(([, head, worktree, stage]) =>
|
|
31
112
|
head !== worktree || head !== stage)) throw new GitPanelError('pullUnsafe');
|