@kubohiroya/turbowarp-plus-editor-git 0.16.0 → 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/src/index.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import React, {useEffect, useMemo, useRef, useState} from 'react';
2
2
  import {createPortal, render, unmountComponentAtNode} from 'react-dom';
3
+ import {CloseButton} from './close-button.js';
3
4
  import {FileDiffView} from './file-diff-view.js';
4
5
  import {findElement, ProjectChangeListView, ProjectElementView, projectChangeLabels, useProjectChanges} from './project-change-list.js';
5
6
  import type {ChangedFile} from './project-change-model.js';
@@ -13,6 +14,7 @@ import {ConnectionSettings} from './connection-settings.js';
13
14
  import type {GitConnectionSettings} from './connection-settings.js';
14
15
  import type {RemoteOperation} from './remote-operations.js';
15
16
  import type {GitAuthor, LocalOperation} from './local-operations.js';
17
+ import {pullRequestBranch} from './local-operations.js';
16
18
  import {PullRequestReviewPanel} from './pull-request-panel.js';
17
19
  import {openProjectSourceTabs} from './project-source-tabs.js';
18
20
  import type {ProjectSourceTabsFile} from './project-source-tabs.js';
@@ -27,17 +29,18 @@ import type {LocatorVM} from './editor-block-locator.js';
27
29
  export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
28
30
  export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch-block-diff-renderer.js';
29
31
 
30
- export {openRepository, createReadOnlyFs, GitPanelError} from './repository.js';
32
+ export {openRepository, createReadOnlyFs, GitPanelError, pullRequestRef} from './repository.js';
31
33
  export {openProjectComparison} from './project-comparison.js';
32
34
  export {openProjectSourceTabs} from './project-source-tabs.js';
33
35
  export type {ProjectSourceTabsFile, ProjectSourceTabsOptions} from './project-source-tabs.js';
34
36
  export type {ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
35
- 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';
36
38
  export {HistoryGraph, laneColors} from './history-graph-view.js';
37
39
  export type {HistoryActionResult, HistoryGraphProps} from './history-graph-view.js';
38
40
  export {layoutHistory} from './history-graph.js';
39
41
  export type {GraphCommit, GraphLayout, GraphRow, GraphSegment} from './history-graph.js';
40
42
  export type {GitAuthor, LocalOperation} from './local-operations.js';
43
+ export {pullRequestBranch} from './local-operations.js';
41
44
 
42
45
  export interface GitHost {
43
46
  connectionSettings?: GitConnectionSettings;
@@ -364,6 +367,12 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
364
367
  notice: operation === 'pull' && current.current.project !== null ? labels.pulledProject : labels.remoteDone};
365
368
  });
366
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]);
367
376
  const [message, setMessage] = useState('');
368
377
  const localOperation = (operation: LocalOperation, notice?: string) => {
369
378
  const repository = current.current.repository;
@@ -400,6 +409,25 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
400
409
  if (operation.kind === 'createBranch' && !operation.fromRemote && !at) setNewBranch('');
401
410
  return result;
402
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
+ };
403
431
  // The history graph reads its own pages after each snapshot, through the same queue as the other reads.
404
432
  const [history, setHistory] = useState<{commits: HistoryCommit[]; cursor: HistoryCursor | null; loading: boolean; failed: boolean}>(
405
433
  {commits: [], cursor: null, loading: false, failed: false});
@@ -485,7 +513,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
485
513
  {!review && state.project !== null && <button disabled={state.busy} onClick={() => void openProject(state.project!)}>{labels.reload}</button>}
486
514
  {state.repository && host.renderReviewBlock && <button type="button" disabled={state.busy}
487
515
  onClick={() => setReview(!review)}>{review ? labels.localChanges : labels.pullRequests}</button>}
488
- <button onClick={closePanel}>{labels.close}</button>
516
+ <CloseButton label={labels.close} onClick={closePanel}/>
489
517
  </header>
490
518
  <div className="twgit-context">
491
519
  <span>{labels.repository} · {state.repository?.root.name || '—'}</span>
@@ -516,7 +544,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
516
544
  {!state.repository && <p>{labels.empty}</p>}
517
545
  {review && state.repository && host.renderReviewBlock &&
518
546
  <PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}
519
- locateFor={reviewLocator} openTabs={openTabs}/>}
547
+ locateFor={reviewLocator} openTabs={openTabs} connection={pullRequestConnection}
548
+ openLocally={branching ? {open: openPullRequest, busy: state.busy} : undefined}/>}
520
549
  {!review && state.repository && <div className="twgit-content">
521
550
  <aside>
522
551
  {state.browser && <details>
@@ -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
- try {
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,5 @@
1
1
  import React, {useEffect, useRef} from 'react';
2
+ import {CloseButton} from './close-button.js';
2
3
  import {render, unmountComponentAtNode} from 'react-dom';
3
4
  import {ProjectSourceComparison} from './project-source-review.js';
4
5
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
@@ -21,6 +22,7 @@ export interface ProjectComparisonOptions {
21
22
  sections: readonly ProjectComparisonSection[];
22
23
  /** The first action is the one the dialog focuses. Closing the dialog or pressing Escape chooses none. */
23
24
  actions: readonly ProjectComparisonAction[];
25
+ /** The tooltip and accessible name of the X button that closes the dialog. By default, "Close" in the locale. */
24
26
  closeLabel?: string;
25
27
  renderBlock: ProjectBlockRenderer;
26
28
  locale?: string;
@@ -32,7 +34,7 @@ export interface ProjectComparisonDialogProps extends ProjectComparisonOptions {
32
34
  const focusable = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]';
33
35
 
34
36
  /** 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',
37
+ export function ProjectComparisonDialog({title, description, notes = [], sections, actions, closeLabel,
36
38
  renderBlock, locale = 'en', onChoose}: ProjectComparisonDialogProps) {
37
39
  const dialog = useRef<HTMLDivElement>(null);
38
40
  const first = useRef<HTMLButtonElement>(null);
@@ -55,7 +57,7 @@ export function ProjectComparisonDialog({title, description, notes = [], section
55
57
  aria-label={title} onKeyDown={keyDown}>
56
58
  <header>
57
59
  <h2>{title}</h2>
58
- <button type="button" onClick={() => onChoose(null)}>{closeLabel}</button>
60
+ <CloseButton label={closeLabel ?? (locale.startsWith('ja') ? '閉じる' : 'Close')} onClick={() => onChoose(null)}/>
59
61
  </header>
60
62
  <div className="twgit-comparison-body">
61
63
  {description && <p>{description}</p>}
@@ -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
- return error instanceof PullRequestError ? labels[error.code] : labels.failed;
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 Pull = Awaited<ReturnType<GitHubPullRequests['load']>>;
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
- void client.load(selection.number, controller.signal).then(data => {
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 && <p role="alert">{list.error}</p>}
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
  }
@@ -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
- 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');
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');