@kubohiroya/turbowarp-plus-editor-git 0.16.1 → 0.18.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 +18 -2
- package/README.md +17 -1
- package/dist/branch-button.d.ts +32 -0
- package/dist/filesystem.d.ts +2 -2
- package/dist/github-pull-requests.d.ts +20 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +739 -158
- package/dist/local-operations.d.ts +8 -1
- package/dist/messages.d.ts +16 -2
- package/dist/{project-asset-paths-KV715791.js → project-asset-paths-BzwVf9HE.js} +653 -1
- package/dist/{project-comparison-1rggIJse.js → project-comparison-BXOQcZ73.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-ByKRBftJ.js} +370 -14
- package/dist/repository.d.ts +39 -0
- package/dist/repository.js +1 -1
- package/dist/style.css +28 -0
- package/package.json +1 -1
- package/src/branch-button.tsx +216 -0
- package/src/filesystem.ts +2 -1
- package/src/github-pull-requests.ts +7 -0
- package/src/index.tsx +213 -67
- package/src/local-operations.ts +23 -3
- package/src/messages.ts +14 -4
- package/src/pull-request-panel.tsx +125 -14
- package/src/remote-operations.ts +84 -3
- package/src/repository.ts +155 -9
- package/src/style.css +28 -0
- package/test/smart-http-fixture.js +20 -0
package/src/index.tsx
CHANGED
|
@@ -5,7 +5,9 @@ import {FileDiffView} from './file-diff-view.js';
|
|
|
5
5
|
import {findElement, ProjectChangeListView, ProjectElementView, projectChangeLabels, useProjectChanges} from './project-change-list.js';
|
|
6
6
|
import type {ChangedFile} from './project-change-model.js';
|
|
7
7
|
import {openRepository} from './repository.js';
|
|
8
|
-
import type {Repository, Snapshot, FileChange, FileDiff, ChangeGroup, HistoryCommit, HistoryCursor} from './repository.js';
|
|
8
|
+
import type {BranchRefs, Repository, Snapshot, FileChange, FileDiff, ChangeGroup, HistoryCommit, HistoryCursor} from './repository.js';
|
|
9
|
+
import {BranchButton} from './branch-button.js';
|
|
10
|
+
import type {BranchButtonState} from './branch-button.js';
|
|
9
11
|
import {HistoryGraph} from './history-graph-view.js';
|
|
10
12
|
import type {HistoryActionResult} from './history-graph-view.js';
|
|
11
13
|
import {labelsFor} from './messages.js';
|
|
@@ -14,6 +16,7 @@ import {ConnectionSettings} from './connection-settings.js';
|
|
|
14
16
|
import type {GitConnectionSettings} from './connection-settings.js';
|
|
15
17
|
import type {RemoteOperation} from './remote-operations.js';
|
|
16
18
|
import type {GitAuthor, LocalOperation} from './local-operations.js';
|
|
19
|
+
import {pullRequestBranch} from './local-operations.js';
|
|
17
20
|
import {PullRequestReviewPanel} from './pull-request-panel.js';
|
|
18
21
|
import {openProjectSourceTabs} from './project-source-tabs.js';
|
|
19
22
|
import type {ProjectSourceTabsFile} from './project-source-tabs.js';
|
|
@@ -28,17 +31,20 @@ import type {LocatorVM} from './editor-block-locator.js';
|
|
|
28
31
|
export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
|
|
29
32
|
export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch-block-diff-renderer.js';
|
|
30
33
|
|
|
31
|
-
export {openRepository, createReadOnlyFs, GitPanelError} from './repository.js';
|
|
34
|
+
export {openRepository, createReadOnlyFs, GitPanelError, pullRequestRef} from './repository.js';
|
|
32
35
|
export {openProjectComparison} from './project-comparison.js';
|
|
33
36
|
export {openProjectSourceTabs} from './project-source-tabs.js';
|
|
34
37
|
export type {ProjectSourceTabsFile, ProjectSourceTabsOptions} from './project-source-tabs.js';
|
|
35
38
|
export type {ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
|
|
36
|
-
export type {Repository, Snapshot, FileChange, FileDiff, HistoryCommit, HistoryCursor, HistoryPage, HistoryRef} from './repository.js';
|
|
39
|
+
export type {BranchRefs, PullRequestBase, PullRequestFetch, PullRequestFiles, Repository, Snapshot, FileChange, FileDiff, HistoryCommit, HistoryCursor, HistoryPage, HistoryRef} from './repository.js';
|
|
40
|
+
export {BranchButton} from './branch-button.js';
|
|
41
|
+
export type {BranchButtonActions, BranchButtonProps, BranchButtonState} from './branch-button.js';
|
|
37
42
|
export {HistoryGraph, laneColors} from './history-graph-view.js';
|
|
38
43
|
export type {HistoryActionResult, HistoryGraphProps} from './history-graph-view.js';
|
|
39
44
|
export {layoutHistory} from './history-graph.js';
|
|
40
45
|
export type {GraphCommit, GraphLayout, GraphRow, GraphSegment} from './history-graph.js';
|
|
41
46
|
export type {GitAuthor, LocalOperation} from './local-operations.js';
|
|
47
|
+
export {pullRequestBranch} from './local-operations.js';
|
|
42
48
|
|
|
43
49
|
export interface GitHost {
|
|
44
50
|
connectionSettings?: GitConnectionSettings;
|
|
@@ -75,7 +81,46 @@ export interface GitPanelRequest {
|
|
|
75
81
|
inactive?: boolean;
|
|
76
82
|
mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
|
|
77
83
|
}
|
|
78
|
-
|
|
84
|
+
type BranchOperation = Extract<LocalOperation, {kind: 'createBranch' | 'switchBranch'}>;
|
|
85
|
+
/** The panel's side of its entries: set by the panel as it renders, used by the branch buttons and `mountGitPanel`'s result. */
|
|
86
|
+
interface Controller {
|
|
87
|
+
open(locale: string): void;
|
|
88
|
+
refresh(automatic?: boolean): Promise<void>;
|
|
89
|
+
/** Asks for a repository, then opens the panel on it. */
|
|
90
|
+
choose(locale: string): Promise<void>;
|
|
91
|
+
changeBranch(operation: BranchOperation): Promise<HistoryActionResult>;
|
|
92
|
+
/** What the branch buttons show. */
|
|
93
|
+
branch: BranchButtonState;
|
|
94
|
+
publish(state: BranchButtonState): void;
|
|
95
|
+
/** Follows `branch`. While anything follows it, the closed panel keeps watching the branches. */
|
|
96
|
+
follow(listener: () => void): () => void;
|
|
97
|
+
/** How many follow `branch`. */
|
|
98
|
+
followers(): number;
|
|
99
|
+
/** Set by the panel while it is mounted, to hear when `followers()` changes. */
|
|
100
|
+
onFollowers(count: number): void;
|
|
101
|
+
}
|
|
102
|
+
function createController(): Controller {
|
|
103
|
+
const listeners = new Set<() => void>();
|
|
104
|
+
const controller: Controller = {
|
|
105
|
+
open() {}, async refresh() {}, async choose() {}, async changeBranch() {return {done: false};},
|
|
106
|
+
branch: {repository: false, branch: undefined, head: null, branches: [], remoteBranches: [], writable: false, busy: false},
|
|
107
|
+
publish(state) {
|
|
108
|
+
controller.branch = state;
|
|
109
|
+
for (const listener of [...listeners]) listener();
|
|
110
|
+
},
|
|
111
|
+
follow(listener) {
|
|
112
|
+
listeners.add(listener);
|
|
113
|
+
controller.onFollowers(listeners.size);
|
|
114
|
+
return () => {
|
|
115
|
+
listeners.delete(listener);
|
|
116
|
+
controller.onFollowers(listeners.size);
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
followers: () => listeners.size,
|
|
120
|
+
onFollowers() {}
|
|
121
|
+
};
|
|
122
|
+
return controller;
|
|
123
|
+
}
|
|
79
124
|
interface State {
|
|
80
125
|
repository: Repository | null;
|
|
81
126
|
snapshot: Snapshot | null;
|
|
@@ -92,6 +137,8 @@ interface State {
|
|
|
92
137
|
const initial: State = {repository: null, snapshot: null, project: null, browser: null,
|
|
93
138
|
changes: [], selected: null, diff: null, commit: null, error: null, busy: false};
|
|
94
139
|
const controllers = new WeakMap<object, Controller>();
|
|
140
|
+
// Branch buttons rendered before the panel is mounted, or after it is disposed, wait for the VM's controller here.
|
|
141
|
+
const registrations = new Set<() => void>();
|
|
95
142
|
type PickerWindow = Window & {showDirectoryPicker?: (options: {id: string; mode: 'readwrite'}) => Promise<FileSystemDirectoryHandle>};
|
|
96
143
|
type ObserverWindow = Window & {FileSystemObserver?: new (callback: () => void) => {
|
|
97
144
|
observe(handle: FileSystemDirectoryHandle, options: {recursive: boolean}): Promise<void>;
|
|
@@ -99,6 +146,53 @@ type ObserverWindow = Window & {FileSystemObserver?: new (callback: () => void)
|
|
|
99
146
|
}};
|
|
100
147
|
const watchInterval = 2000;
|
|
101
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Calls `check` every `watchInterval`, when the window regains focus and shortly after a File System Observer reports
|
|
151
|
+
* a change under `root`, one call at a time and never while the page is hidden. Returns the function that stops it.
|
|
152
|
+
*/
|
|
153
|
+
function watchRepository(document: Document, root: FileSystemDirectoryHandle, check: (stopped: () => boolean) => Promise<void>) {
|
|
154
|
+
const view = document.defaultView as ObserverWindow;
|
|
155
|
+
let stopped = false;
|
|
156
|
+
let checking = false;
|
|
157
|
+
const run = async () => {
|
|
158
|
+
if (stopped || checking || document.visibilityState === 'hidden') return;
|
|
159
|
+
checking = true;
|
|
160
|
+
try {
|
|
161
|
+
await check(() => stopped);
|
|
162
|
+
} catch {
|
|
163
|
+
// Entries can disappear while another tool writes. The next check reads them again.
|
|
164
|
+
} finally {
|
|
165
|
+
checking = false;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
let soon: number | undefined;
|
|
169
|
+
const schedule = () => {view.clearTimeout(soon); soon = view.setTimeout(() => void run(), 300);};
|
|
170
|
+
const interval = view.setInterval(() => void run(), watchInterval);
|
|
171
|
+
const onFocus = () => void run();
|
|
172
|
+
view.addEventListener('focus', onFocus);
|
|
173
|
+
document.addEventListener('visibilitychange', onFocus);
|
|
174
|
+
let observer: InstanceType<NonNullable<ObserverWindow['FileSystemObserver']>> | null = null;
|
|
175
|
+
try {
|
|
176
|
+
if (view.FileSystemObserver) {
|
|
177
|
+
observer = new view.FileSystemObserver(schedule);
|
|
178
|
+
observer.observe(root, {recursive: true}).catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
observer = null;
|
|
182
|
+
}
|
|
183
|
+
void run();
|
|
184
|
+
return () => {
|
|
185
|
+
stopped = true;
|
|
186
|
+
view.clearInterval(interval);
|
|
187
|
+
view.clearTimeout(soon);
|
|
188
|
+
view.removeEventListener('focus', onFocus);
|
|
189
|
+
document.removeEventListener('visibilitychange', onFocus);
|
|
190
|
+
observer?.disconnect();
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const sameRefs = (a: BranchRefs | null, b: BranchRefs) => !!a && a.branch === b.branch && a.head === b.head &&
|
|
194
|
+
a.branches.join('\n') === b.branches.join('\n') && a.remoteBranches.join('\n') === b.remoteBranches.join('\n');
|
|
195
|
+
|
|
102
196
|
function GitPanel({vm, document, host, controller}: {vm: object; document: Document; host: GitHost; controller: Controller}) {
|
|
103
197
|
const [visible, setVisible] = useState(false);
|
|
104
198
|
// A layout tab that holds the panel. The panel component stays mounted, so the repository survives closing the tab.
|
|
@@ -231,61 +325,48 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
231
325
|
void controller.refresh();
|
|
232
326
|
}
|
|
233
327
|
}, [shown]);
|
|
328
|
+
// The branches the branch buttons show: those of the last snapshot, or newer ones read while the panel is not in view.
|
|
329
|
+
const [refs, setRefs] = useState<BranchRefs | null>(null);
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (!state.repository) setRefs(null);
|
|
332
|
+
else if (state.snapshot) setRefs(refsOf(state.snapshot));
|
|
333
|
+
}, [state.repository, state.snapshot]);
|
|
334
|
+
const [followers, setFollowers] = useState(controller.followers());
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
controller.onFollowers = setFollowers;
|
|
337
|
+
// A button may have started following before the panel was mounted.
|
|
338
|
+
setFollowers(controller.followers());
|
|
339
|
+
return () => {controller.onFollowers = () => {};};
|
|
340
|
+
}, []);
|
|
234
341
|
// While the panel is open, notice Git commands, agents and editors that change the repository outside the Editor.
|
|
342
|
+
// While it is out of view and a branch button follows it, read only the refs at the same moments, so the button
|
|
343
|
+
// follows branch changes; the panel reads everything again when it comes into view.
|
|
344
|
+
const following = followers > 0;
|
|
235
345
|
useEffect(() => {
|
|
236
346
|
const repository = state.repository;
|
|
237
|
-
if (!
|
|
238
|
-
|
|
239
|
-
let stopped = false;
|
|
240
|
-
let checking = false;
|
|
241
|
-
const check = async () => {
|
|
347
|
+
if (!repository || !shown && !following) return;
|
|
348
|
+
return watchRepository(document, repository.root, async stopped => {
|
|
242
349
|
const {snapshot} = current.current;
|
|
350
|
+
if (running.current || current.current.repository !== repository) return;
|
|
243
351
|
// A tab behind another one has no boxes; it reads again when it is shown.
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
246
|
-
!
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (stopped || probe === 'locked' || probe === snapshot.probe || probe === handled.current ||
|
|
251
|
-
current.current.snapshot !== snapshot) return;
|
|
252
|
-
handled.current = probe;
|
|
253
|
-
const {selected, commit} = current.current;
|
|
254
|
-
// Keep an open diff, commit or review in place and ask for a refresh instead.
|
|
255
|
-
if (selected || commit || reviewing.current) {
|
|
256
|
-
setState(previous => previous.snapshot === snapshot ? {...previous, notice: labelsFor(locale).outdated} : previous);
|
|
257
|
-
} else await controller.refresh(true);
|
|
258
|
-
} catch {
|
|
259
|
-
// Entries can disappear while another tool writes. The next check reads them again.
|
|
260
|
-
} finally {
|
|
261
|
-
checking = false;
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
let soon: number | undefined;
|
|
265
|
-
const schedule = () => {view.clearTimeout(soon); soon = view.setTimeout(() => void check(), 300);};
|
|
266
|
-
const interval = view.setInterval(() => void check(), watchInterval);
|
|
267
|
-
const onFocus = () => void check();
|
|
268
|
-
view.addEventListener('focus', onFocus);
|
|
269
|
-
document.addEventListener('visibilitychange', onFocus);
|
|
270
|
-
let observer: InstanceType<NonNullable<ObserverWindow['FileSystemObserver']>> | null = null;
|
|
271
|
-
try {
|
|
272
|
-
if (view.FileSystemObserver) {
|
|
273
|
-
observer = new view.FileSystemObserver(schedule);
|
|
274
|
-
observer.observe(repository.root, {recursive: true}).catch(() => {});
|
|
352
|
+
const inView = shown && !!dialog.current && dialog.current.getClientRects().length > 0;
|
|
353
|
+
if (!inView || !snapshot) {
|
|
354
|
+
if (!following) return;
|
|
355
|
+
const next = await repository.refs();
|
|
356
|
+
if (!stopped() && current.current.repository === repository) setRefs(previous => sameRefs(previous, next) ? previous : next);
|
|
357
|
+
return;
|
|
275
358
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
};
|
|
288
|
-
}, [shown, state.repository, locale]);
|
|
359
|
+
const probe = await repository.probe(snapshot.scope);
|
|
360
|
+
if (stopped() || probe === 'locked' || probe === snapshot.probe || probe === handled.current ||
|
|
361
|
+
current.current.snapshot !== snapshot) return;
|
|
362
|
+
handled.current = probe;
|
|
363
|
+
const {selected, commit} = current.current;
|
|
364
|
+
// Keep an open diff, commit or review in place and ask for a refresh instead.
|
|
365
|
+
if (selected || commit || reviewing.current) {
|
|
366
|
+
setState(previous => previous.snapshot === snapshot ? {...previous, notice: labelsFor(locale).outdated} : previous);
|
|
367
|
+
} else await controller.refresh(true);
|
|
368
|
+
});
|
|
369
|
+
}, [shown, following, state.repository, locale]);
|
|
289
370
|
useEffect(() => {
|
|
290
371
|
if (!visible || slot) return;
|
|
291
372
|
const previous = document.activeElement as HTMLElement | null;
|
|
@@ -294,7 +375,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
294
375
|
}, [visible, slot]);
|
|
295
376
|
const chooseRoot = async () => {
|
|
296
377
|
const view = document.defaultView as PickerWindow;
|
|
297
|
-
|
|
378
|
+
return run(async () => {
|
|
298
379
|
let root: FileSystemDirectoryHandle;
|
|
299
380
|
try {root = await view.showDirectoryPicker!({id: 'turbowarp-plus-git-root', mode: 'readwrite'});} catch (error) {
|
|
300
381
|
if ((error as DOMException).name === 'AbortError') return {};
|
|
@@ -365,6 +446,12 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
365
446
|
notice: operation === 'pull' && current.current.project !== null ? labels.pulledProject : labels.remoteDone};
|
|
366
447
|
});
|
|
367
448
|
};
|
|
449
|
+
// A connected GitHub connection reads pull request contents over Git Smart HTTP, so private repositories work too.
|
|
450
|
+
const pullRequestConnection = useMemo(() => {
|
|
451
|
+
const settings = host.connectionSettings;
|
|
452
|
+
const getSession = settings?.getSession;
|
|
453
|
+
return getSession ? {getSession, connected: () => settings.getView().state.status === 'connected'} : undefined;
|
|
454
|
+
}, [host]);
|
|
368
455
|
const [message, setMessage] = useState('');
|
|
369
456
|
const localOperation = (operation: LocalOperation, notice?: string) => {
|
|
370
457
|
const repository = current.current.repository;
|
|
@@ -386,7 +473,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
386
473
|
};
|
|
387
474
|
const [newBranch, setNewBranch] = useState('');
|
|
388
475
|
const [switchAfterCreate, setSwitchAfterCreate] = useState(true);
|
|
389
|
-
const changeBranch = async (operation:
|
|
476
|
+
const changeBranch = async (operation: BranchOperation): Promise<HistoryActionResult> => {
|
|
390
477
|
const repository = current.current.repository;
|
|
391
478
|
const project = current.current.project;
|
|
392
479
|
const switching = operation.kind === 'switchBranch' || operation.checkout;
|
|
@@ -401,6 +488,25 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
401
488
|
if (operation.kind === 'createBranch' && !operation.fromRemote && !at) setNewBranch('');
|
|
402
489
|
return result;
|
|
403
490
|
};
|
|
491
|
+
/**
|
|
492
|
+
* Opens a pull request version fetched over the connection: creates `pr/<number>` at its head (or reuses that
|
|
493
|
+
* branch when it already points there), switches to it with the same refusals as any switch, and opens the project
|
|
494
|
+
* folder again from it. The branch the person was on stays, so switching back is an ordinary switch.
|
|
495
|
+
*/
|
|
496
|
+
const openPullRequest = async (number: number, head: string): Promise<HistoryActionResult> => {
|
|
497
|
+
const repository = current.current.repository;
|
|
498
|
+
if (!repository) return {done: false};
|
|
499
|
+
const original = current.current.snapshot?.branch ?? null;
|
|
500
|
+
const name = pullRequestBranch(number);
|
|
501
|
+
const result = await changeBranch({kind: 'createBranch', name, at: head, checkout: true, reuse: true});
|
|
502
|
+
if (!result.done) return result;
|
|
503
|
+
const project = current.current.project;
|
|
504
|
+
if (project !== null && await repository.hasFolder(name, project)) await openProject(project);
|
|
505
|
+
const notice = (original && original !== name ? labels.openedPullRequest : labels.openedPullRequestDetached)
|
|
506
|
+
.replace('{number}', String(number)).replace('{branch}', name).replace('{previous}', original ?? '');
|
|
507
|
+
if (alive.current) setState(previous => previous.error ? previous : {...previous, notice});
|
|
508
|
+
return result;
|
|
509
|
+
};
|
|
404
510
|
// The history graph reads its own pages after each snapshot, through the same queue as the other reads.
|
|
405
511
|
const [history, setHistory] = useState<{commits: HistoryCommit[]; cursor: HistoryCursor | null; loading: boolean; failed: boolean}>(
|
|
406
512
|
{commits: [], cursor: null, loading: false, failed: false});
|
|
@@ -445,6 +551,18 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
445
551
|
// One loader per selected change, so thumbnails read each asset once.
|
|
446
552
|
const loadAsset = useMemo(() => state.repository && state.snapshot && state.selected ?
|
|
447
553
|
state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
|
|
554
|
+
controller.changeBranch = changeBranch;
|
|
555
|
+
controller.choose = async language => {
|
|
556
|
+
setLocale(language);
|
|
557
|
+
const before = current.current.repository;
|
|
558
|
+
const result = await chooseRoot();
|
|
559
|
+
// The panel shows the chosen repository, or why it could not be read; a cancelled choice opens nothing.
|
|
560
|
+
if (!result.done && result.error || current.current.repository !== before) controller.open(language);
|
|
561
|
+
};
|
|
562
|
+
const branchState: BranchButtonState = {repository: !!state.repository, branch: refs?.branch, head: refs?.head ?? null,
|
|
563
|
+
branches: refs?.branches ?? [], remoteBranches: refs?.remoteBranches ?? [], writable: !!host.localWrite && !!refs, busy: state.busy};
|
|
564
|
+
const published = JSON.stringify(branchState);
|
|
565
|
+
useEffect(() => controller.publish(branchState), [published]);
|
|
448
566
|
if (!shown) return null;
|
|
449
567
|
const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
|
|
450
568
|
// Branches can be switched and created whenever writes are allowed and the repository has been read.
|
|
@@ -517,7 +635,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
517
635
|
{!state.repository && <p>{labels.empty}</p>}
|
|
518
636
|
{review && state.repository && host.renderReviewBlock &&
|
|
519
637
|
<PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}
|
|
520
|
-
locateFor={reviewLocator} openTabs={openTabs}
|
|
638
|
+
locateFor={reviewLocator} openTabs={openTabs} connection={pullRequestConnection}
|
|
639
|
+
openLocally={branching ? {open: openPullRequest, busy: state.busy} : undefined}/>}
|
|
521
640
|
{!review && state.repository && <div className="twgit-content">
|
|
522
641
|
<aside>
|
|
523
642
|
{state.browser && <details>
|
|
@@ -628,28 +747,55 @@ function upstreamText({upstream}: Snapshot, labels: ReturnType<typeof labelsFor>
|
|
|
628
747
|
}
|
|
629
748
|
}
|
|
630
749
|
|
|
750
|
+
const refsOf = ({branch, head, branches, remoteBranches}: Snapshot): BranchRefs => ({branch, head, branches, remoteBranches});
|
|
751
|
+
const announce = () => {for (const listener of [...registrations]) listener();};
|
|
752
|
+
|
|
631
753
|
export function mountGitPanel({vm, document, host}: {vm: object; document: Document; host: GitHost}) {
|
|
632
754
|
if (!(document.defaultView as PickerWindow)?.showDirectoryPicker) return null;
|
|
633
755
|
const root = document.createElement('div');
|
|
634
756
|
document.body.append(root);
|
|
635
|
-
const controller
|
|
757
|
+
const controller = createController();
|
|
636
758
|
controllers.set(vm, controller);
|
|
637
759
|
render(<GitPanel vm={vm} document={document} host={host} controller={controller}/>, root);
|
|
638
|
-
|
|
760
|
+
announce();
|
|
761
|
+
return {refresh: () => controller.refresh(), open: (locale: string) => controller.open(locale), dispose() {
|
|
639
762
|
if (controllers.get(vm) === controller) controllers.delete(vm);
|
|
640
763
|
void host.connectionSettings?.disconnect().catch(() => {});
|
|
641
764
|
unmountComponentAtNode(root);
|
|
642
765
|
root.remove();
|
|
766
|
+
announce();
|
|
643
767
|
}};
|
|
644
768
|
}
|
|
645
769
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
},
|
|
770
|
+
/** The branch button of the panel mounted for `vm`, or nothing while no panel is mounted for it. */
|
|
771
|
+
function BranchEntry({vm, locale}: {vm: object; locale: string}) {
|
|
772
|
+
const [controller, setController] = useState(() => controllers.get(vm));
|
|
773
|
+
useEffect(() => {
|
|
774
|
+
const update = () => setController(controllers.get(vm));
|
|
775
|
+
registrations.add(update);
|
|
776
|
+
update();
|
|
777
|
+
return () => {registrations.delete(update);};
|
|
778
|
+
}, [vm]);
|
|
779
|
+
const [state, setState] = useState(controller?.branch);
|
|
780
|
+
useEffect(() => {
|
|
781
|
+
if (!controller) return;
|
|
782
|
+
const update = () => setState(controller.branch);
|
|
783
|
+
update();
|
|
784
|
+
return controller.follow(update);
|
|
785
|
+
}, [controller]);
|
|
786
|
+
if (!controller || !state) return null;
|
|
787
|
+
return <BranchButton state={state} locale={locale} open={() => controller.open(locale)} choose={() => void controller.choose(locale)}
|
|
788
|
+
switchBranch={name => controller.changeBranch({kind: 'switchBranch', name})}
|
|
789
|
+
createBranch={(name, checkout) => controller.changeBranch({kind: 'createBranch', name, checkout})}
|
|
790
|
+
createFromRemote={name => controller.changeBranch({kind: 'createBranch', name, fromRemote: true, checkout: true})}/>;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Renders the button for the host's menu bar that shows the current branch of the panel mounted for `vm` and opens the
|
|
795
|
+
* menu of branches, the panel and the repository choice. It renders nothing while no panel is mounted for `vm`, such as
|
|
796
|
+
* when the host does not offer local Git, and shares the panel's repository, operations and checks.
|
|
797
|
+
*/
|
|
798
|
+
export function renderBranchButton(createElement: (component: any, props: any, ...children: any[]) => any,
|
|
799
|
+
{vm, locale = 'en'}: {vm: object; locale?: string}) {
|
|
800
|
+
return createElement(BranchEntry, {vm, locale});
|
|
655
801
|
}
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const messages = {
|
|
2
2
|
en: {
|
|
3
3
|
pullRequests: 'Pull requests', localChanges: 'Local changes',
|
|
4
|
-
title: 'Local Git',
|
|
4
|
+
title: 'Local Git', close: 'Close', choose: 'Choose Repository…', refresh: 'Refresh',
|
|
5
5
|
empty: 'Choose an existing Git repository to inspect its files and history.',
|
|
6
6
|
repository: 'Repository', project: 'Project folder', none: 'No project selected', root: 'Repository root',
|
|
7
7
|
open: 'Open This Project', reload: 'Reload Project', up: 'Parent folder', browse: 'Choose a project inside this repository',
|
|
@@ -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).',
|
|
@@ -50,11 +54,12 @@ export const messages = {
|
|
|
50
54
|
historyMore: 'Show more', historyLoading: 'Reading records…', historyFailed: 'Could not read the records.',
|
|
51
55
|
historyActions: 'Actions', historyCreateHere: 'Create branch here…', historySwitch: 'Switch to {name}',
|
|
52
56
|
historyCreateFromRemote: 'Create and switch to {name}', historyCopy: 'Copy commit ID', historyCopied: 'Copied the commit ID.',
|
|
53
|
-
historyCopyFailed: 'Could not copy the commit ID.', historyRefs: 'Branches', cancel: 'Cancel'
|
|
57
|
+
historyCopyFailed: 'Could not copy the commit ID.', historyRefs: 'Branches', cancel: 'Cancel',
|
|
58
|
+
branchMenu: 'Branches', openPanel: 'Open Local Git', newBranchItem: 'New Branch…', newBranchTitle: 'New Branch'
|
|
54
59
|
},
|
|
55
60
|
ja: {
|
|
56
61
|
pullRequests: 'プルリクエスト', localChanges: 'ローカルの変更',
|
|
57
|
-
title: 'ローカル Git',
|
|
62
|
+
title: 'ローカル Git', close: '閉じる', choose: 'リポジトリを選択…', refresh: '再取得',
|
|
58
63
|
empty: '既存の Git リポジトリを選び、ファイルの状態と履歴を確認できます。',
|
|
59
64
|
repository: 'リポジトリ', project: '作品フォルダ', none: '作品は未選択', root: 'リポジトリルート',
|
|
60
65
|
open: 'この作品を開く', reload: '作品を再読込', up: '上のフォルダ', browse: 'リポジトリ内の作品を選択',
|
|
@@ -73,6 +78,10 @@ export const messages = {
|
|
|
73
78
|
commitMessage: 'この記録の説明を書いてください。',
|
|
74
79
|
switchUnsafe: 'ブランチを切り替える前に、実行を止め、すべての変更を保存して記録(または取り消し)してください。',
|
|
75
80
|
branchExists: '同じ名前のブランチがすでにあります。',
|
|
81
|
+
branchConflict: 'この名前は既存のブランチとぶつかります。Git では、pr と pr/7 のように、あるブランチと、その名前に / を続けたブランチを両方は持てません。Git でもう一方の名前を変えるか消すか、別の名前にしてください。',
|
|
82
|
+
pullRequestBranch: 'ブランチ pr/<番号> が、別の記録を指してすでにあるため、この版を開きませんでした。ブランチはそのままです。「ローカルの変更」で切り替えるか、Git で名前を変えるか消してから、もう一度ここで開いてください。',
|
|
83
|
+
openedPullRequest: 'プルリクエスト #{number} を、ローカルブランチ {branch} で開きました。戻るときは「ローカルの変更」で {previous} に切り替えてください。',
|
|
84
|
+
openedPullRequestDetached: 'プルリクエスト #{number} を、ローカルブランチ {branch} で開きました。戻るときは「ローカルの変更」で別のブランチに切り替えてください。',
|
|
76
85
|
locked: 'ほかの Git の操作が、このリポジトリを使っています(ロックファイル、またはマージ・cherry-pick・revert・rebase の途中)。終わってから、もう一度試してください。',
|
|
77
86
|
branchName: 'この名前は Git のブランチ名に使えません。',
|
|
78
87
|
pullUnsafe: 'GitHub から取り込む前に、実行を止め、保存していない編集とまだ記録していない変更をなくしてください。',
|
|
@@ -103,7 +112,8 @@ export const messages = {
|
|
|
103
112
|
historyMore: 'さらに表示', historyLoading: '記録を読込中…', historyFailed: '記録を読み込めませんでした。',
|
|
104
113
|
historyActions: '操作', historyCreateHere: 'ここからブランチを作る…', historySwitch: '{name} に切り替える',
|
|
105
114
|
historyCreateFromRemote: '{name} を作って切り替える', historyCopy: 'コミットの ID をコピー', historyCopied: 'コミットの ID をコピーしました。',
|
|
106
|
-
historyCopyFailed: 'コミットの ID をコピーできませんでした。', historyRefs: 'ブランチ', cancel: 'キャンセル'
|
|
115
|
+
historyCopyFailed: 'コミットの ID をコピーできませんでした。', historyRefs: 'ブランチ', cancel: 'キャンセル',
|
|
116
|
+
branchMenu: 'ブランチ', openPanel: 'ローカル Git を開く', newBranchItem: '新しいブランチ…', newBranchTitle: '新しいブランチ'
|
|
107
117
|
}
|
|
108
118
|
};
|
|
109
119
|
export type Labels = typeof messages.en;
|