@kubohiroya/turbowarp-plus-editor-git 0.15.0 → 0.16.1
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 +5 -3
- package/README.md +5 -3
- package/dist/close-button.d.ts +6 -0
- package/dist/file-diff-view.d.ts +3 -1
- package/dist/filesystem.d.ts +2 -2
- package/dist/history-graph-view.d.ts +40 -0
- package/dist/history-graph.d.ts +44 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +697 -31
- package/dist/local-operations.d.ts +14 -0
- package/dist/messages.d.ts +52 -0
- package/dist/{project-comparison-LAyP_beL.js → project-comparison-1rggIJse.js} +240 -20
- package/dist/project-comparison.d.ts +1 -0
- package/dist/project-source-diff.d.ts +2 -0
- package/dist/project-source-diff.js +2 -2
- package/dist/project-source-review.d.ts +43 -0
- package/dist/project-source-tabs.d.ts +35 -0
- package/dist/pull-request-panel.d.ts +6 -1
- package/dist/{repository-Cx-mxT9l.js → repository-BYno-5f8.js} +491 -4
- package/dist/repository.d.ts +44 -0
- package/dist/repository.js +1 -1
- package/dist/style.css +31 -4
- package/package.json +1 -1
- package/src/close-button.tsx +10 -0
- package/src/file-diff-view.tsx +7 -3
- package/src/filesystem.ts +2 -1
- package/src/history-graph-view.tsx +233 -0
- package/src/history-graph.ts +90 -0
- package/src/index.tsx +137 -17
- package/src/local-operations.ts +59 -4
- package/src/messages.ts +26 -2
- package/src/project-comparison.tsx +4 -2
- package/src/project-source-diff.tsx +2 -0
- package/src/project-source-review.tsx +127 -15
- package/src/project-source-tabs.tsx +118 -0
- package/src/pull-request-panel.tsx +26 -5
- package/src/repository.ts +155 -1
- package/src/style.css +31 -4
package/src/index.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
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';
|
|
6
7
|
import {openRepository} from './repository.js';
|
|
7
|
-
import type {Repository, Snapshot, FileChange, FileDiff, ChangeGroup} from './repository.js';
|
|
8
|
+
import type {Repository, Snapshot, FileChange, FileDiff, ChangeGroup, HistoryCommit, HistoryCursor} from './repository.js';
|
|
9
|
+
import {HistoryGraph} from './history-graph-view.js';
|
|
10
|
+
import type {HistoryActionResult} from './history-graph-view.js';
|
|
8
11
|
import {labelsFor} from './messages.js';
|
|
9
12
|
import {GitPanelError} from './filesystem.js';
|
|
10
13
|
import {ConnectionSettings} from './connection-settings.js';
|
|
@@ -12,6 +15,9 @@ import type {GitConnectionSettings} from './connection-settings.js';
|
|
|
12
15
|
import type {RemoteOperation} from './remote-operations.js';
|
|
13
16
|
import type {GitAuthor, LocalOperation} from './local-operations.js';
|
|
14
17
|
import {PullRequestReviewPanel} from './pull-request-panel.js';
|
|
18
|
+
import {openProjectSourceTabs} from './project-source-tabs.js';
|
|
19
|
+
import type {ProjectSourceTabsFile} from './project-source-tabs.js';
|
|
20
|
+
import {parseProjectSource} from './project-source-patch.js';
|
|
15
21
|
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
16
22
|
import type {ScratchBlockLocationWorkspace} from './block-diff-location.js';
|
|
17
23
|
export {createScratchBlocksDiffLocator} from './block-diff-location.js';
|
|
@@ -24,8 +30,14 @@ export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch
|
|
|
24
30
|
|
|
25
31
|
export {openRepository, createReadOnlyFs, GitPanelError} from './repository.js';
|
|
26
32
|
export {openProjectComparison} from './project-comparison.js';
|
|
33
|
+
export {openProjectSourceTabs} from './project-source-tabs.js';
|
|
34
|
+
export type {ProjectSourceTabsFile, ProjectSourceTabsOptions} from './project-source-tabs.js';
|
|
27
35
|
export type {ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
|
|
28
|
-
export type {Repository, Snapshot, FileChange, FileDiff} from './repository.js';
|
|
36
|
+
export type {Repository, Snapshot, FileChange, FileDiff, HistoryCommit, HistoryCursor, HistoryPage, HistoryRef} from './repository.js';
|
|
37
|
+
export {HistoryGraph, laneColors} from './history-graph-view.js';
|
|
38
|
+
export type {HistoryActionResult, HistoryGraphProps} from './history-graph-view.js';
|
|
39
|
+
export {layoutHistory} from './history-graph.js';
|
|
40
|
+
export type {GraphCommit, GraphLayout, GraphRow, GraphSegment} from './history-graph.js';
|
|
29
41
|
export type {GitAuthor, LocalOperation} from './local-operations.js';
|
|
30
42
|
|
|
31
43
|
export interface GitHost {
|
|
@@ -55,6 +67,12 @@ export interface GitPanelRequest {
|
|
|
55
67
|
filename: string;
|
|
56
68
|
title?: string;
|
|
57
69
|
transient?: boolean;
|
|
70
|
+
/** Tabs of one area share a split panel of the host's layout. */
|
|
71
|
+
area?: string;
|
|
72
|
+
/** Gathers the tab with the others of the same `id` under a labelled chip; `color` is a palette id such as `'green'`. */
|
|
73
|
+
tabGroup?: {id: string; label: string; color?: string; collapsed?: boolean};
|
|
74
|
+
/** Opens the tab behind the active one. */
|
|
75
|
+
inactive?: boolean;
|
|
58
76
|
mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
|
|
59
77
|
}
|
|
60
78
|
interface Controller {open(locale: string): void; refresh(automatic?: boolean): Promise<void>}
|
|
@@ -112,9 +130,10 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
112
130
|
const labels = labelsFor(locale);
|
|
113
131
|
// A background refresh never drops a user's operation: the operation waits for that refresh instead.
|
|
114
132
|
const background = useRef<Promise<void> | null>(null);
|
|
115
|
-
|
|
133
|
+
/** Whether an operation ran to the end, or the message the panel shows for why it did not. */
|
|
134
|
+
const run = async (operation: () => Promise<Partial<State>>, automatic = false): Promise<HistoryActionResult> => {
|
|
116
135
|
if (running.current && !automatic && background.current) await background.current;
|
|
117
|
-
if (running.current || !alive.current) return;
|
|
136
|
+
if (running.current || !alive.current) return {done: false};
|
|
118
137
|
let settle = () => {};
|
|
119
138
|
if (automatic) background.current = new Promise(resolve => {settle = resolve;});
|
|
120
139
|
running.current = true;
|
|
@@ -126,17 +145,19 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
126
145
|
try {
|
|
127
146
|
const update = await operation();
|
|
128
147
|
if (epoch.current === ticket) setState(previous => ({...previous, ...update, busy: automatic ? previous.busy : false}));
|
|
148
|
+
return {done: true};
|
|
129
149
|
} catch (error) {
|
|
130
|
-
if (epoch.current !== ticket) return;
|
|
150
|
+
if (epoch.current !== ticket) return {done: false};
|
|
131
151
|
const code = error instanceof GitPanelError ? error.code : 'failed';
|
|
132
152
|
// A background read that met another tool still writing tries again on the next check, without an alert.
|
|
133
153
|
if (automatic && code === 'changed') {
|
|
134
154
|
handled.current = null;
|
|
135
|
-
return;
|
|
155
|
+
return {done: false};
|
|
136
156
|
}
|
|
137
157
|
const message = labels[code as keyof typeof labels] || labels.failed;
|
|
138
158
|
setState(previous => ({...previous, error: automatic && previous.error ? previous.error : message,
|
|
139
159
|
busy: automatic ? previous.busy : false, diff: null}));
|
|
160
|
+
return {done: false, error: message};
|
|
140
161
|
} finally {
|
|
141
162
|
running.current = false;
|
|
142
163
|
// A result dropped by a newer load still ends the busy state, unless a refresh follows at once.
|
|
@@ -347,23 +368,74 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
347
368
|
const [message, setMessage] = useState('');
|
|
348
369
|
const localOperation = (operation: LocalOperation, notice?: string) => {
|
|
349
370
|
const repository = current.current.repository;
|
|
350
|
-
if (!repository || !host.localWrite) return Promise.resolve();
|
|
371
|
+
if (!repository || !host.localWrite) return Promise.resolve<HistoryActionResult>({done: false});
|
|
351
372
|
return run(async () => {
|
|
352
373
|
const ticket = epoch.current;
|
|
353
374
|
await repository.localOperation(operation, () => {
|
|
354
375
|
if (epoch.current !== ticket) throw new GitPanelError('changed');
|
|
355
376
|
// Staging records the saved files; edits still in the Editor would be left out without a word.
|
|
356
377
|
if (operation.kind === 'stage' && host.isProjectDirty()) throw new GitPanelError('saveFirst');
|
|
378
|
+
// Switching replaces the files the Editor opened, like pull.
|
|
379
|
+
if ((operation.kind === 'switchBranch' || (operation.kind === 'createBranch' && operation.checkout)) &&
|
|
380
|
+
(host.isProjectDirty() || host.isProjectRunning?.())) throw new GitPanelError('switchUnsafe');
|
|
357
381
|
});
|
|
358
382
|
if (operation.kind === 'commit') setMessage('');
|
|
359
383
|
const snapshot = await repository.read(current.current.project ?? '');
|
|
360
384
|
return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null, notice};
|
|
361
385
|
});
|
|
362
386
|
};
|
|
387
|
+
const [newBranch, setNewBranch] = useState('');
|
|
388
|
+
const [switchAfterCreate, setSwitchAfterCreate] = useState(true);
|
|
389
|
+
const changeBranch = async (operation: Extract<LocalOperation, {kind: 'createBranch' | 'switchBranch'}>): Promise<HistoryActionResult> => {
|
|
390
|
+
const repository = current.current.repository;
|
|
391
|
+
const project = current.current.project;
|
|
392
|
+
const switching = operation.kind === 'switchBranch' || operation.checkout;
|
|
393
|
+
if (!repository) return {done: false};
|
|
394
|
+
// Tell before switching when the open project folder is not on the branch.
|
|
395
|
+
const at = operation.kind === 'createBranch' ? operation.at : undefined;
|
|
396
|
+
if (switching && project && !(operation.kind === 'createBranch' && !operation.fromRemote && !at) &&
|
|
397
|
+
!await (at ? repository.hasFolder(at, project, 'commit') :
|
|
398
|
+
repository.hasFolder(operation.name, project, operation.kind === 'createBranch' && operation.fromRemote)) &&
|
|
399
|
+
!document.defaultView?.confirm(labels.projectMissing)) return {done: false};
|
|
400
|
+
const result = await localOperation(operation, switching ? labels.switched : labels.branchCreated);
|
|
401
|
+
if (operation.kind === 'createBranch' && !operation.fromRemote && !at) setNewBranch('');
|
|
402
|
+
return result;
|
|
403
|
+
};
|
|
404
|
+
// The history graph reads its own pages after each snapshot, through the same queue as the other reads.
|
|
405
|
+
const [history, setHistory] = useState<{commits: HistoryCommit[]; cursor: HistoryCursor | null; loading: boolean; failed: boolean}>(
|
|
406
|
+
{commits: [], cursor: null, loading: false, failed: false});
|
|
407
|
+
const historyTicket = useRef(0);
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
const ticket = ++historyTicket.current;
|
|
410
|
+
const repository = state.repository;
|
|
411
|
+
if (!repository || !state.snapshot) {
|
|
412
|
+
setHistory({commits: [], cursor: null, loading: false, failed: false});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
// The rows read before stay until the new page arrives, so focus in the list is kept.
|
|
416
|
+
setHistory(previous => ({...previous, cursor: null, loading: true, failed: false}));
|
|
417
|
+
repository.history().then(page => {
|
|
418
|
+
if (historyTicket.current === ticket) setHistory({commits: page.commits, cursor: page.cursor, loading: false, failed: false});
|
|
419
|
+
}, () => {
|
|
420
|
+
if (historyTicket.current === ticket) setHistory({commits: [], cursor: null, loading: false, failed: true});
|
|
421
|
+
});
|
|
422
|
+
}, [state.repository, state.snapshot]);
|
|
423
|
+
const showMoreHistory = () => {
|
|
424
|
+
const repository = state.repository;
|
|
425
|
+
if (!repository || !history.cursor || history.loading) return;
|
|
426
|
+
const ticket = historyTicket.current;
|
|
427
|
+
setHistory(previous => ({...previous, loading: true}));
|
|
428
|
+
repository.history({after: history.cursor}).then(page => {
|
|
429
|
+
if (historyTicket.current === ticket) setHistory(previous => ({commits: [...previous.commits, ...page.commits],
|
|
430
|
+
cursor: page.cursor, loading: false, failed: false}));
|
|
431
|
+
}, () => {
|
|
432
|
+
if (historyTicket.current === ticket) setHistory(previous => ({...previous, cursor: null, loading: false, failed: true}));
|
|
433
|
+
});
|
|
434
|
+
};
|
|
363
435
|
const focusTrap = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
364
436
|
if (event.key === 'Escape') {event.stopPropagation(); closePanel(); return;}
|
|
365
437
|
if (event.key !== 'Tab') return;
|
|
366
|
-
const items = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]')]
|
|
438
|
+
const items = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled):not([tabindex="-1"]), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]')]
|
|
367
439
|
.filter(item => item.getClientRects().length > 0);
|
|
368
440
|
const first = items[0], last = items[items.length - 1];
|
|
369
441
|
if (event.shiftKey && (document.activeElement === first || document.activeElement === event.currentTarget)) {
|
|
@@ -375,6 +447,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
375
447
|
state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
|
|
376
448
|
if (!shown) return null;
|
|
377
449
|
const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
|
|
450
|
+
// Branches can be switched and created whenever writes are allowed and the repository has been read.
|
|
451
|
+
const branching = !!host.localWrite && !!state.snapshot;
|
|
378
452
|
// Writing works on the current changes, not on a record from the history, and only on a branch.
|
|
379
453
|
const writable = !!host.localWrite && !state.commit && !!state.snapshot?.branch;
|
|
380
454
|
const unrecorded = [...new Set(state.changes.filter(change => change.group === 'unstaged' || change.group === 'untracked')
|
|
@@ -390,6 +464,20 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
390
464
|
const reviewLocator = (path: string, headSha: string) => locator && path === projectFile && state.snapshot?.head === headSha &&
|
|
391
465
|
!state.changes.length ? locator : undefined;
|
|
392
466
|
const selection = projectList && !state.selected ? findElement(projectList.list, element) : null;
|
|
467
|
+
// Only a tab leaves room for more tabs beside it; the modal dialog keeps its comparisons inside.
|
|
468
|
+
const openTabs = slot && host.openPanel && host.renderReviewBlock ?
|
|
469
|
+
(key: string, files: readonly ProjectSourceTabsFile[], rememberAs?: string) =>
|
|
470
|
+
openProjectSourceTabs(host.openPanel, {key, files, renderBlock: host.renderReviewBlock!, locale, rememberAs}) : undefined;
|
|
471
|
+
const openFileTabs = (change: FileChange) => openTabs && ((before: string | null, after: string | null) => {
|
|
472
|
+
let files: ProjectSourceTabsFile[];
|
|
473
|
+
try {
|
|
474
|
+
files = [{path: change.path, before: parseProjectSource(before), after: parseProjectSource(after),
|
|
475
|
+
loadAsset: loadAsset ?? undefined, locateBlock: fileLocator(change)}];
|
|
476
|
+
} catch {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
openTabs(`${state.commit ?? change.group}:${change.path}`, files);
|
|
480
|
+
});
|
|
393
481
|
const content = <>
|
|
394
482
|
<header>
|
|
395
483
|
<h2>{labels.title}</h2>
|
|
@@ -398,7 +486,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
398
486
|
{!review && state.project !== null && <button disabled={state.busy} onClick={() => void openProject(state.project!)}>{labels.reload}</button>}
|
|
399
487
|
{state.repository && host.renderReviewBlock && <button type="button" disabled={state.busy}
|
|
400
488
|
onClick={() => setReview(!review)}>{review ? labels.localChanges : labels.pullRequests}</button>}
|
|
401
|
-
<
|
|
489
|
+
<CloseButton label={labels.close} onClick={closePanel}/>
|
|
402
490
|
</header>
|
|
403
491
|
<div className="twgit-context">
|
|
404
492
|
<span>{labels.repository} · {state.repository?.root.name || '—'}</span>
|
|
@@ -429,7 +517,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
429
517
|
{!state.repository && <p>{labels.empty}</p>}
|
|
430
518
|
{review && state.repository && host.renderReviewBlock &&
|
|
431
519
|
<PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}
|
|
432
|
-
locateFor={reviewLocator}/>}
|
|
520
|
+
locateFor={reviewLocator} openTabs={openTabs}/>}
|
|
433
521
|
{!review && state.repository && <div className="twgit-content">
|
|
434
522
|
<aside>
|
|
435
523
|
{state.browser && <details>
|
|
@@ -446,7 +534,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
446
534
|
{(() => {
|
|
447
535
|
const fileList = groups.map(group => <section key={group}>
|
|
448
536
|
<h3>{labels[group]} · {state.changes.filter(change => change.group === group).length}</h3>
|
|
449
|
-
<ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}
|
|
537
|
+
<ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}
|
|
538
|
+
className={writable ? 'twgit-file-row' : undefined}>
|
|
450
539
|
<button className="twgit-file" data-git-group={group} disabled={state.busy}
|
|
451
540
|
aria-pressed={state.selected === change} onClick={() => openFile(change)}>
|
|
452
541
|
<small className="twgit-change-kind">{!change.after ? labels.deleted : !change.before ? labels.added : labels.modified}</small> {change.path}
|
|
@@ -471,19 +560,50 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
471
560
|
onClick={() => void localOperation({kind: 'commit', message, author: host.localWrite?.author?.()},
|
|
472
561
|
labels.committed)}>{labels.commitButton}</button>
|
|
473
562
|
</section>}
|
|
563
|
+
{branching && state.snapshot && <section className="twgit-branches" aria-label={labels.branches}>
|
|
564
|
+
<h3>{labels.branches}</h3>
|
|
565
|
+
<ul>{state.snapshot.branches.map(name => <li key={name}><code>{name}</code>
|
|
566
|
+
{name === state.snapshot!.branch ? <small> · {labels.currentBranch}</small> :
|
|
567
|
+
<button type="button" disabled={state.busy} aria-label={`${labels.switchTo}: ${name}`}
|
|
568
|
+
onClick={() => void changeBranch({kind: 'switchBranch', name})}>{labels.switchTo}</button>}
|
|
569
|
+
</li>)}</ul>
|
|
570
|
+
{state.snapshot.head && <form onSubmit={event => {
|
|
571
|
+
event.preventDefault();
|
|
572
|
+
if (newBranch.trim()) void changeBranch({kind: 'createBranch', name: newBranch.trim(), checkout: switchAfterCreate});
|
|
573
|
+
}}>
|
|
574
|
+
<label>{labels.newBranch} <input value={newBranch} disabled={state.busy}
|
|
575
|
+
onChange={event => setNewBranch(event.currentTarget.value)}/></label>
|
|
576
|
+
<label><input type="checkbox" checked={switchAfterCreate} disabled={state.busy}
|
|
577
|
+
onChange={event => setSwitchAfterCreate(event.currentTarget.checked)}/> {labels.switchAfterCreate}</label>
|
|
578
|
+
<button type="submit" disabled={state.busy || !newBranch.trim()}>{labels.createBranch}</button>
|
|
579
|
+
</form>}
|
|
580
|
+
{state.snapshot.remoteBranches.length > 0 && <>
|
|
581
|
+
<h3>{labels.remoteBranches}</h3>
|
|
582
|
+
<ul>{state.snapshot.remoteBranches.map(name => <li key={name}><code>origin/{name}</code>
|
|
583
|
+
<button type="button" disabled={state.busy} aria-label={`${labels.createFromRemote}: origin/${name}`}
|
|
584
|
+
onClick={() => void changeBranch({kind: 'createBranch', name, fromRemote: true, checkout: true})}>
|
|
585
|
+
{labels.createFromRemote}</button></li>)}</ul>
|
|
586
|
+
</>}
|
|
587
|
+
</section>}
|
|
474
588
|
<h3>{labels.history}</h3>
|
|
475
|
-
<
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
589
|
+
{state.snapshot && <HistoryGraph commits={history.commits} selected={state.commit} locale={locale} busy={state.busy}
|
|
590
|
+
onSelect={oid => void selectCommit(oid)} hasMore={!!history.cursor} loadingMore={history.loading}
|
|
591
|
+
onShowMore={showMoreHistory} localBranches={state.snapshot.branches}
|
|
592
|
+
{...branching ? {
|
|
593
|
+
onCreateBranch: (oid: string, name: string, checkout: boolean) =>
|
|
594
|
+
changeBranch({kind: 'createBranch', name, at: oid, checkout}),
|
|
595
|
+
onSwitchBranch: (name: string) => changeBranch({kind: 'switchBranch', name}),
|
|
596
|
+
onCreateFromRemote: (name: string) => changeBranch({kind: 'createBranch', name, fromRemote: true, checkout: true})
|
|
597
|
+
} : {}}/>}
|
|
598
|
+
{history.loading && !history.commits.length && <p>{labels.historyLoading}</p>}
|
|
599
|
+
{history.failed && <p>{labels.historyFailed}</p>}
|
|
480
600
|
</aside>
|
|
481
601
|
<main className="twgit-diff">
|
|
482
602
|
{selection && projectList ? <ProjectElementView changes={projectList} selection={selection} locale={locale}
|
|
483
603
|
locateBlock={state.commit ? undefined : locator}
|
|
484
604
|
renderBlock={host.renderReviewBlock} onSelectFile={openFile}/> :
|
|
485
605
|
state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
|
|
486
|
-
locateBlock={fileLocator(state.selected)}
|
|
606
|
+
locateBlock={fileLocator(state.selected)} onOpenTabs={openFileTabs(state.selected)}
|
|
487
607
|
renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
|
|
488
608
|
</main>
|
|
489
609
|
</div>}
|
package/src/local-operations.ts
CHANGED
|
@@ -7,7 +7,13 @@ export type GitAuthor = {name: string; email: string};
|
|
|
7
7
|
export type LocalOperation =
|
|
8
8
|
| {kind: 'stage'; paths: readonly string[]}
|
|
9
9
|
| {kind: 'unstage'; paths: readonly string[]}
|
|
10
|
-
| {kind: 'commit'; message: string; author?: GitAuthor}
|
|
10
|
+
| {kind: 'commit'; message: string; author?: GitAuthor}
|
|
11
|
+
/**
|
|
12
|
+
* Creates `name` at HEAD, at the commit `at`, or at `origin/<name>` with `fromRemote`, and switches to it with
|
|
13
|
+
* `checkout`.
|
|
14
|
+
*/
|
|
15
|
+
| {kind: 'createBranch'; name: string; fromRemote?: boolean; at?: string; checkout: boolean}
|
|
16
|
+
| {kind: 'switchBranch'; name: string};
|
|
11
17
|
|
|
12
18
|
const missing = (error: unknown) => ['ENOENT', 'ENOTDIR', 'NotFoundError'].includes(errorCode(error) ?? '');
|
|
13
19
|
|
|
@@ -19,7 +25,8 @@ const missing = (error: unknown) => ['ENOENT', 'ENOTDIR', 'NotFoundError'].inclu
|
|
|
19
25
|
export async function runLocalOperation(root: FileSystemDirectoryHandle, operation: LocalOperation, guard: () => void) {
|
|
20
26
|
const execute = async () => {
|
|
21
27
|
guard();
|
|
22
|
-
const
|
|
28
|
+
const switching = operation.kind === 'switchBranch' || (operation.kind === 'createBranch' && operation.checkout);
|
|
29
|
+
const transaction = createMutationFs(root, switching);
|
|
23
30
|
const options = {fs: transaction.fs, dir: '/', gitdir: '/.git', cache: {}};
|
|
24
31
|
const read = createReadOnlyFs(root).promises;
|
|
25
32
|
const exists = async (path: string) => {
|
|
@@ -34,9 +41,57 @@ export async function runLocalOperation(root: FileSystemDirectoryHandle, operati
|
|
|
34
41
|
// Another tool is in the middle of a merge, rebase or its own write: leave the repository to it.
|
|
35
42
|
for (const path of ['/.git/index.lock', '/.git/MERGE_HEAD', '/.git/CHERRY_PICK_HEAD', '/.git/REVERT_HEAD',
|
|
36
43
|
'/.git/rebase-merge', '/.git/rebase-apply']) {
|
|
37
|
-
if (await exists(path)) throw new GitPanelError('
|
|
44
|
+
if (await exists(path)) throw new GitPanelError('locked');
|
|
38
45
|
}
|
|
39
|
-
if (operation.kind === '
|
|
46
|
+
if (operation.kind === 'createBranch' || operation.kind === 'switchBranch') {
|
|
47
|
+
const name = operation.name.trim();
|
|
48
|
+
const local = `refs/heads/${name}`;
|
|
49
|
+
if (switching) {
|
|
50
|
+
// Like pull: switching rewrites the work tree, so it starts from a clean one.
|
|
51
|
+
if ((await git.statusMatrix({...options, refresh: false})).some(([, head, worktree, stage]) =>
|
|
52
|
+
head !== worktree || head !== stage)) throw new GitPanelError('switchUnsafe');
|
|
53
|
+
}
|
|
54
|
+
if (operation.kind === 'createBranch') {
|
|
55
|
+
let object: string;
|
|
56
|
+
if (operation.at !== undefined) {
|
|
57
|
+
if (operation.fromRemote || !/^[0-9a-f]{40}$/.test(operation.at)) throw new GitPanelError('invalid');
|
|
58
|
+
object = await git.readCommit({...options, oid: operation.at})
|
|
59
|
+
.then(() => operation.at!, () => {throw new GitPanelError('invalid');});
|
|
60
|
+
} else if (operation.fromRemote) {
|
|
61
|
+
try {
|
|
62
|
+
object = await git.resolveRef({...options, ref: `refs/remotes/origin/${name}`});
|
|
63
|
+
} catch {
|
|
64
|
+
throw new GitPanelError('invalid');
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
object = await git.resolveRef({...options, ref: 'HEAD'}).catch(() => {throw new GitPanelError('unsupported');});
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
// The ref first: isomorphic-git's checkout of a missing branch would write tracking config.
|
|
71
|
+
await git.branch({...options, ref: name, object, checkout: false});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const code = (error as {code?: string})?.code;
|
|
74
|
+
if (code === 'AlreadyExistsError') throw new GitPanelError('branchExists');
|
|
75
|
+
if (code === 'InvalidRefNameError') throw new GitPanelError('branchName');
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
} else if (!(await git.listBranches(options)).includes(name)) {
|
|
79
|
+
throw new GitPanelError('invalid');
|
|
80
|
+
}
|
|
81
|
+
if (switching) {
|
|
82
|
+
// Executable bits, links and submodules cannot be written from the browser.
|
|
83
|
+
const commit = await git.resolveRef({...options, ref: local});
|
|
84
|
+
await git.walk({...options, trees: [git.TREE({ref: commit})], map: async (path, [entry]) => {
|
|
85
|
+
if (!entry || path === '.') return true;
|
|
86
|
+
const type = await entry.type();
|
|
87
|
+
if (type === 'tree') return true;
|
|
88
|
+
if (type !== 'blob' || await entry.mode() !== 0o100644) throw new GitPanelError('unsupported');
|
|
89
|
+
return true;
|
|
90
|
+
}});
|
|
91
|
+
await git.checkout({...options, ref: name});
|
|
92
|
+
if (await git.currentBranch(options) !== name) throw new GitPanelError('changed');
|
|
93
|
+
}
|
|
94
|
+
} else if (operation.kind === 'stage') {
|
|
40
95
|
// The browser cannot see executable bits or links: staging such a file would change its mode in Git.
|
|
41
96
|
const modes = new Map<string, number>();
|
|
42
97
|
await git.walk({...options, trees: [git.STAGE()], map: async (path, [entry]) => {
|
package/src/messages.ts
CHANGED
|
@@ -18,6 +18,10 @@ export const messages = {
|
|
|
18
18
|
outdated: 'The repository changed outside the Editor. Refresh to read the current state.',
|
|
19
19
|
unsupported: 'This repository contains an entry the browser cannot represent.',
|
|
20
20
|
commitMessage: 'Write a message for this record.',
|
|
21
|
+
switchUnsafe: 'Stop the project, and save or record (or undo) every change, before switching branches.',
|
|
22
|
+
branchExists: 'A branch with this name already exists.',
|
|
23
|
+
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
|
+
branchName: 'Git cannot use this name for a branch.',
|
|
21
25
|
pullUnsafe: 'Stop the project, and save or undo unsaved edits and unrecorded changes, before getting changes from GitHub (pull).',
|
|
22
26
|
diverged: 'This branch and GitHub\'s have both gained records, so they cannot be combined here (pull only fast-forwards). Your branch and files are unchanged.',
|
|
23
27
|
session: 'Reconnect GitHub for this repository and requested permission.',
|
|
@@ -32,13 +36,21 @@ export const messages = {
|
|
|
32
36
|
saveFirst: 'Save the project before adding its changes to the next record.',
|
|
33
37
|
author: 'Set user.name and user.email for this repository in Git before recording. The Editor does not change Git settings.',
|
|
34
38
|
nothingStaged: 'Add changes to the next record first.',
|
|
39
|
+
switchTo: 'Switch', currentBranch: 'current', createBranch: 'Create Branch', newBranch: 'New branch name',
|
|
40
|
+
switchAfterCreate: 'Switch to it', createFromRemote: 'Create and Switch', remoteBranches: 'Branches on GitHub (origin)',
|
|
41
|
+
switched: 'Switched branches. Use Reload Project to open the files of this branch.', branchCreated: 'Created the branch.',
|
|
42
|
+
projectMissing: 'The open project folder is not on this branch. Switch anyway?',
|
|
35
43
|
pulledProject: 'Got the changes from GitHub. Use Reload Project to open the updated files.',
|
|
36
44
|
confirmPush: 'Send the records on this branch to GitHub (push)?',
|
|
37
45
|
details: 'Git details', branch: 'Branch', head: 'HEAD', groups: 'Git groups', added: 'Added', modified: 'Changed', deleted: 'Deleted',
|
|
38
46
|
gitStaged: 'Staged', gitUnstaged: 'Unstaged', gitUntracked: 'Untracked',
|
|
39
47
|
upstreamSame: 'Same as GitHub', upstreamAhead: 'Ahead of GitHub', upstreamBehind: 'Behind GitHub', upstreamDiverged: 'Diverged from GitHub',
|
|
40
48
|
upstreamUnknown: 'Not checked with GitHub yet', records: 'records', checkedAt: 'as of the last check',
|
|
41
|
-
failed: 'Could not read the repository. Check folder access and refresh.'
|
|
49
|
+
failed: 'Could not read the repository. Check folder access and refresh.',
|
|
50
|
+
historyMore: 'Show more', historyLoading: 'Reading records…', historyFailed: 'Could not read the records.',
|
|
51
|
+
historyActions: 'Actions', historyCreateHere: 'Create branch here…', historySwitch: 'Switch to {name}',
|
|
52
|
+
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'
|
|
42
54
|
},
|
|
43
55
|
ja: {
|
|
44
56
|
pullRequests: 'プルリクエスト', localChanges: 'ローカルの変更',
|
|
@@ -59,6 +71,10 @@ export const messages = {
|
|
|
59
71
|
outdated: 'Editor の外でリポジトリが変更されました。再取得すると最新の状態を読み込みます。',
|
|
60
72
|
unsupported: 'ブラウザーで扱えない種類の Git エントリーがあります。',
|
|
61
73
|
commitMessage: 'この記録の説明を書いてください。',
|
|
74
|
+
switchUnsafe: 'ブランチを切り替える前に、実行を止め、すべての変更を保存して記録(または取り消し)してください。',
|
|
75
|
+
branchExists: '同じ名前のブランチがすでにあります。',
|
|
76
|
+
locked: 'ほかの Git の操作が、このリポジトリを使っています(ロックファイル、またはマージ・cherry-pick・revert・rebase の途中)。終わってから、もう一度試してください。',
|
|
77
|
+
branchName: 'この名前は Git のブランチ名に使えません。',
|
|
62
78
|
pullUnsafe: 'GitHub から取り込む前に、実行を止め、保存していない編集とまだ記録していない変更をなくしてください。',
|
|
63
79
|
diverged: 'このブランチと GitHub の両方に新しい記録があるため、ここでは取り込めません(pull は fast-forward のみ)。ブランチとファイルはそのままです。',
|
|
64
80
|
session: 'このリポジトリと操作に必要な権限で GitHub に接続してください。',
|
|
@@ -73,13 +89,21 @@ export const messages = {
|
|
|
73
89
|
saveFirst: '次の記録に入れる前に、作品を保存してください。',
|
|
74
90
|
author: '記録する前に、Git でこのリポジトリの user.name と user.email を設定してください。Editor は Git の設定を変えません。',
|
|
75
91
|
nothingStaged: '先に、変更を次の記録に入れてください。',
|
|
92
|
+
switchTo: '切り替える', currentBranch: '現在', createBranch: 'ブランチを作る', newBranch: '新しいブランチの名前',
|
|
93
|
+
switchAfterCreate: '作ったら切り替える', createFromRemote: '作って切り替える', remoteBranches: 'GitHub(origin)のブランチ',
|
|
94
|
+
switched: 'ブランチを切り替えました。「作品を再読込」で、このブランチのファイルを開けます。', branchCreated: 'ブランチを作りました。',
|
|
95
|
+
projectMissing: '開いている作品フォルダは、このブランチにありません。切り替えますか?',
|
|
76
96
|
pulledProject: 'GitHub から取り込みました。「作品を再読込」で更新したファイルを開けます。',
|
|
77
97
|
confirmPush: 'このブランチの記録を GitHub に送りますか(push)?',
|
|
78
98
|
details: 'Git の詳細', branch: 'ブランチ', head: 'HEAD', groups: 'Git の区分', added: '追加', modified: '変更', deleted: '削除',
|
|
79
99
|
gitStaged: 'ステージ済み (staged)', gitUnstaged: '未ステージ (unstaged)', gitUntracked: '未追跡 (untracked)',
|
|
80
100
|
upstreamSame: 'GitHub と同じ', upstreamAhead: 'GitHub より進んでいる', upstreamBehind: 'GitHub より遅れている', upstreamDiverged: 'GitHub と分かれている',
|
|
81
101
|
upstreamUnknown: 'まだ GitHub と確かめていない', records: '件', checkedAt: '最後に確かめた時点',
|
|
82
|
-
failed: 'リポジトリを読み込めませんでした。フォルダへのアクセスを確認し、再取得してください。'
|
|
102
|
+
failed: 'リポジトリを読み込めませんでした。フォルダへのアクセスを確認し、再取得してください。',
|
|
103
|
+
historyMore: 'さらに表示', historyLoading: '記録を読込中…', historyFailed: '記録を読み込めませんでした。',
|
|
104
|
+
historyActions: '操作', historyCreateHere: 'ここからブランチを作る…', historySwitch: '{name} に切り替える',
|
|
105
|
+
historyCreateFromRemote: '{name} を作って切り替える', historyCopy: 'コミットの ID をコピー', historyCopied: 'コミットの ID をコピーしました。',
|
|
106
|
+
historyCopyFailed: 'コミットの ID をコピーできませんでした。', historyRefs: 'ブランチ', cancel: 'キャンセル'
|
|
83
107
|
}
|
|
84
108
|
};
|
|
85
109
|
export type Labels = typeof messages.en;
|
|
@@ -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
|
|
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
|
-
<
|
|
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>}
|
|
@@ -10,6 +10,8 @@ import './project-source-diff.css';
|
|
|
10
10
|
export {createGitHubPullRequests, gitHubRepositoryFromOrigin} from './github-pull-requests.js';
|
|
11
11
|
export {ProjectSourceComparison, ProjectSourceReview} from './project-source-review.js';
|
|
12
12
|
export {ProjectComparisonDialog, openProjectComparison} from './project-comparison.js';
|
|
13
|
+
export {openProjectSourceTabs} from './project-source-tabs.js';
|
|
14
|
+
export type {ProjectSourceTabsFile, ProjectSourceTabsOptions} from './project-source-tabs.js';
|
|
13
15
|
export type {ProjectComparisonAction, ProjectComparisonDialogProps, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
|
|
14
16
|
export type {ProjectSourceComparisonProps, ProjectSourceReviewFile, ProjectSourceReviewProps} from './project-source-review.js';
|
|
15
17
|
|