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