@kubohiroya/turbowarp-plus-editor-git 0.15.0 → 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 +5 -3
- package/README.md +5 -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 +18 -1
- package/dist/index.js +693 -30
- package/dist/local-operations.d.ts +14 -0
- package/dist/messages.d.ts +52 -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-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 +27 -4
- package/package.json +1 -1
- 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 +135 -16
- package/src/local-operations.ts +59 -4
- package/src/messages.ts +26 -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 +27 -4
package/src/index.tsx
CHANGED
|
@@ -4,7 +4,9 @@ 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';
|
|
@@ -12,6 +14,9 @@ import type {GitConnectionSettings} from './connection-settings.js';
|
|
|
12
14
|
import type {RemoteOperation} from './remote-operations.js';
|
|
13
15
|
import type {GitAuthor, LocalOperation} from './local-operations.js';
|
|
14
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';
|
|
15
20
|
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
16
21
|
import type {ScratchBlockLocationWorkspace} from './block-diff-location.js';
|
|
17
22
|
export {createScratchBlocksDiffLocator} from './block-diff-location.js';
|
|
@@ -24,8 +29,14 @@ export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch
|
|
|
24
29
|
|
|
25
30
|
export {openRepository, createReadOnlyFs, GitPanelError} from './repository.js';
|
|
26
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';
|
|
27
34
|
export type {ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
|
|
28
|
-
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';
|
|
29
40
|
export type {GitAuthor, LocalOperation} from './local-operations.js';
|
|
30
41
|
|
|
31
42
|
export interface GitHost {
|
|
@@ -55,6 +66,12 @@ export interface GitPanelRequest {
|
|
|
55
66
|
filename: string;
|
|
56
67
|
title?: string;
|
|
57
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;
|
|
58
75
|
mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
|
|
59
76
|
}
|
|
60
77
|
interface Controller {open(locale: string): void; refresh(automatic?: boolean): Promise<void>}
|
|
@@ -112,9 +129,10 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
112
129
|
const labels = labelsFor(locale);
|
|
113
130
|
// A background refresh never drops a user's operation: the operation waits for that refresh instead.
|
|
114
131
|
const background = useRef<Promise<void> | null>(null);
|
|
115
|
-
|
|
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> => {
|
|
116
134
|
if (running.current && !automatic && background.current) await background.current;
|
|
117
|
-
if (running.current || !alive.current) return;
|
|
135
|
+
if (running.current || !alive.current) return {done: false};
|
|
118
136
|
let settle = () => {};
|
|
119
137
|
if (automatic) background.current = new Promise(resolve => {settle = resolve;});
|
|
120
138
|
running.current = true;
|
|
@@ -126,17 +144,19 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
126
144
|
try {
|
|
127
145
|
const update = await operation();
|
|
128
146
|
if (epoch.current === ticket) setState(previous => ({...previous, ...update, busy: automatic ? previous.busy : false}));
|
|
147
|
+
return {done: true};
|
|
129
148
|
} catch (error) {
|
|
130
|
-
if (epoch.current !== ticket) return;
|
|
149
|
+
if (epoch.current !== ticket) return {done: false};
|
|
131
150
|
const code = error instanceof GitPanelError ? error.code : 'failed';
|
|
132
151
|
// A background read that met another tool still writing tries again on the next check, without an alert.
|
|
133
152
|
if (automatic && code === 'changed') {
|
|
134
153
|
handled.current = null;
|
|
135
|
-
return;
|
|
154
|
+
return {done: false};
|
|
136
155
|
}
|
|
137
156
|
const message = labels[code as keyof typeof labels] || labels.failed;
|
|
138
157
|
setState(previous => ({...previous, error: automatic && previous.error ? previous.error : message,
|
|
139
158
|
busy: automatic ? previous.busy : false, diff: null}));
|
|
159
|
+
return {done: false, error: message};
|
|
140
160
|
} finally {
|
|
141
161
|
running.current = false;
|
|
142
162
|
// A result dropped by a newer load still ends the busy state, unless a refresh follows at once.
|
|
@@ -347,23 +367,74 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
347
367
|
const [message, setMessage] = useState('');
|
|
348
368
|
const localOperation = (operation: LocalOperation, notice?: string) => {
|
|
349
369
|
const repository = current.current.repository;
|
|
350
|
-
if (!repository || !host.localWrite) return Promise.resolve();
|
|
370
|
+
if (!repository || !host.localWrite) return Promise.resolve<HistoryActionResult>({done: false});
|
|
351
371
|
return run(async () => {
|
|
352
372
|
const ticket = epoch.current;
|
|
353
373
|
await repository.localOperation(operation, () => {
|
|
354
374
|
if (epoch.current !== ticket) throw new GitPanelError('changed');
|
|
355
375
|
// Staging records the saved files; edits still in the Editor would be left out without a word.
|
|
356
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');
|
|
357
380
|
});
|
|
358
381
|
if (operation.kind === 'commit') setMessage('');
|
|
359
382
|
const snapshot = await repository.read(current.current.project ?? '');
|
|
360
383
|
return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null, notice};
|
|
361
384
|
});
|
|
362
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
|
+
};
|
|
363
434
|
const focusTrap = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
364
435
|
if (event.key === 'Escape') {event.stopPropagation(); closePanel(); return;}
|
|
365
436
|
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"]')]
|
|
437
|
+
const items = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled):not([tabindex="-1"]), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]')]
|
|
367
438
|
.filter(item => item.getClientRects().length > 0);
|
|
368
439
|
const first = items[0], last = items[items.length - 1];
|
|
369
440
|
if (event.shiftKey && (document.activeElement === first || document.activeElement === event.currentTarget)) {
|
|
@@ -375,6 +446,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
375
446
|
state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
|
|
376
447
|
if (!shown) return null;
|
|
377
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;
|
|
378
451
|
// Writing works on the current changes, not on a record from the history, and only on a branch.
|
|
379
452
|
const writable = !!host.localWrite && !state.commit && !!state.snapshot?.branch;
|
|
380
453
|
const unrecorded = [...new Set(state.changes.filter(change => change.group === 'unstaged' || change.group === 'untracked')
|
|
@@ -390,6 +463,20 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
390
463
|
const reviewLocator = (path: string, headSha: string) => locator && path === projectFile && state.snapshot?.head === headSha &&
|
|
391
464
|
!state.changes.length ? locator : undefined;
|
|
392
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
|
+
});
|
|
393
480
|
const content = <>
|
|
394
481
|
<header>
|
|
395
482
|
<h2>{labels.title}</h2>
|
|
@@ -429,7 +516,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
429
516
|
{!state.repository && <p>{labels.empty}</p>}
|
|
430
517
|
{review && state.repository && host.renderReviewBlock &&
|
|
431
518
|
<PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}
|
|
432
|
-
locateFor={reviewLocator}/>}
|
|
519
|
+
locateFor={reviewLocator} openTabs={openTabs}/>}
|
|
433
520
|
{!review && state.repository && <div className="twgit-content">
|
|
434
521
|
<aside>
|
|
435
522
|
{state.browser && <details>
|
|
@@ -446,7 +533,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
446
533
|
{(() => {
|
|
447
534
|
const fileList = groups.map(group => <section key={group}>
|
|
448
535
|
<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}
|
|
536
|
+
<ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}
|
|
537
|
+
className={writable ? 'twgit-file-row' : undefined}>
|
|
450
538
|
<button className="twgit-file" data-git-group={group} disabled={state.busy}
|
|
451
539
|
aria-pressed={state.selected === change} onClick={() => openFile(change)}>
|
|
452
540
|
<small className="twgit-change-kind">{!change.after ? labels.deleted : !change.before ? labels.added : labels.modified}</small> {change.path}
|
|
@@ -471,19 +559,50 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
|
|
|
471
559
|
onClick={() => void localOperation({kind: 'commit', message, author: host.localWrite?.author?.()},
|
|
472
560
|
labels.committed)}>{labels.commitButton}</button>
|
|
473
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>}
|
|
474
587
|
<h3>{labels.history}</h3>
|
|
475
|
-
<
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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>}
|
|
480
599
|
</aside>
|
|
481
600
|
<main className="twgit-diff">
|
|
482
601
|
{selection && projectList ? <ProjectElementView changes={projectList} selection={selection} locale={locale}
|
|
483
602
|
locateBlock={state.commit ? undefined : locator}
|
|
484
603
|
renderBlock={host.renderReviewBlock} onSelectFile={openFile}/> :
|
|
485
604
|
state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
|
|
486
|
-
locateBlock={fileLocator(state.selected)}
|
|
605
|
+
locateBlock={fileLocator(state.selected)} onOpenTabs={openFileTabs(state.selected)}
|
|
487
606
|
renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
|
|
488
607
|
</main>
|
|
489
608
|
</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;
|
|
@@ -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
|
|
|
@@ -124,19 +124,27 @@ function useBlockLocation(comparison: Comparison, locateBlock?: ProjectBlockLoca
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
/** What a script is called in a review: its hat block, or both hat blocks when the change replaced it. */
|
|
128
|
+
function scriptReviewTitle(script: ScriptReview, labels: typeof ja) {
|
|
129
|
+
const beforeTitle = script.before[0] && scriptTitle(script.before[0], labels);
|
|
130
|
+
const afterTitle = script.after[0] && scriptTitle(script.after[0], labels);
|
|
131
|
+
return beforeTitle && afterTitle && beforeTitle !== afterTitle ? beforeTitle + ' → ' + afterTitle : afterTitle ?? beforeTitle;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset, location, expanded = false}: {
|
|
128
135
|
script: ScriptReview; labels: typeof ja; reviewed?: boolean; setReviewed?(value: boolean): void;
|
|
129
136
|
renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader; location?: BlockLocation;
|
|
137
|
+
/** Starts open, as in a tab that holds this one script. */
|
|
138
|
+
expanded?: boolean;
|
|
130
139
|
}) {
|
|
131
|
-
const [open, setOpen] = useState(
|
|
140
|
+
const [open, setOpen] = useState(expanded);
|
|
132
141
|
const {sync, toggle} = usePreviewSync(labels === ja ? 'ja' : 'en');
|
|
133
142
|
const status = script.kind;
|
|
134
143
|
const locale = labels === ja ? 'ja' : 'en';
|
|
135
144
|
const summary = script.summary.map(item => summaryPhrase(item, locale)).join(labels === ja ? '、' : ', ');
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return <details className="twgit-review-script" onToggle={event => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
|
145
|
+
const title = scriptReviewTitle(script, labels);
|
|
146
|
+
return <details className="twgit-review-script" open={expanded || undefined}
|
|
147
|
+
onToggle={event => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
|
140
148
|
<summary>
|
|
141
149
|
<span className="twgit-review-status">{labels[status]}</span>
|
|
142
150
|
<strong>{title}</strong>
|
|
@@ -339,8 +347,21 @@ const scriptBlocks = ({target, id}: ScriptReview['before'][number]) => {
|
|
|
339
347
|
};
|
|
340
348
|
const scriptPrint = (script: ScriptReview) => fingerprint([script.kind, script.before.map(scriptBlocks), script.after.map(scriptBlocks)]);
|
|
341
349
|
const storageKey = (name: string) => `turbowarp-plus-git:reviewed:${name}`;
|
|
350
|
+
// Marks of reviews that are not remembered, shared by the tabs of one comparison while the page is open.
|
|
351
|
+
const memoryMarks = new Map<string, Record<string, string>>();
|
|
352
|
+
// Whoever shows the marks of a review, such as the review panel and the tabs of the same pull request.
|
|
353
|
+
const markListeners = new Map<string, Set<() => void>>();
|
|
354
|
+
const subscribeMarks = (name: string, listener: () => void) => {
|
|
355
|
+
const listeners = markListeners.get(name) ?? new Set();
|
|
356
|
+
markListeners.set(name, listeners.add(listener));
|
|
357
|
+
return () => {
|
|
358
|
+
listeners.delete(listener);
|
|
359
|
+
if (!listeners.size) markListeners.delete(name);
|
|
360
|
+
};
|
|
361
|
+
};
|
|
342
362
|
// Browser storage can be missing or refuse access; the marks then last while the review is open.
|
|
343
|
-
const readMarks = (name: string): Record<string, string> => {
|
|
363
|
+
const readMarks = (name: string, remember = true): Record<string, string> => {
|
|
364
|
+
if (!remember) return memoryMarks.get(name) ?? {};
|
|
344
365
|
try {
|
|
345
366
|
const value = JSON.parse(globalThis.localStorage?.getItem(storageKey(name)) ?? '{}');
|
|
346
367
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
@@ -348,13 +369,18 @@ const readMarks = (name: string): Record<string, string> => {
|
|
|
348
369
|
return {};
|
|
349
370
|
}
|
|
350
371
|
};
|
|
351
|
-
const writeMarks = (name: string, marks: Record<string, string
|
|
372
|
+
const writeMarks = (name: string, marks: Record<string, string>, remember = true) => {
|
|
373
|
+
if (!remember) {
|
|
374
|
+
if (Object.keys(marks).length) memoryMarks.set(name, marks);
|
|
375
|
+
else memoryMarks.delete(name);
|
|
376
|
+
}
|
|
352
377
|
try {
|
|
353
|
-
if (Object.keys(marks).length) globalThis.localStorage?.setItem(storageKey(name), JSON.stringify(marks));
|
|
354
|
-
else globalThis.localStorage?.removeItem(storageKey(name));
|
|
378
|
+
if (remember && Object.keys(marks).length) globalThis.localStorage?.setItem(storageKey(name), JSON.stringify(marks));
|
|
379
|
+
else if (remember) globalThis.localStorage?.removeItem(storageKey(name));
|
|
355
380
|
} catch {
|
|
356
381
|
// Not kept; the marks still work for this review.
|
|
357
382
|
}
|
|
383
|
+
markListeners.get(name)?.forEach(listener => listener());
|
|
358
384
|
};
|
|
359
385
|
|
|
360
386
|
export function ProjectSourceReview({title, description, verification, files, renderBlock, locale = 'en', rememberAs}: ProjectSourceReviewProps) {
|
|
@@ -379,18 +405,24 @@ export function ProjectSourceReview({title, description, verification, files, re
|
|
|
379
405
|
return prints;
|
|
380
406
|
}, [entries]);
|
|
381
407
|
const [checked, setChecked] = useState<{entries: typeof entries; marks: Record<string, string>}>();
|
|
408
|
+
// A mark set in a tab of the same pull request shows here too.
|
|
409
|
+
const [, refresh] = useState(0);
|
|
410
|
+
useEffect(() => rememberAs ? subscribeMarks(rememberAs, () => {
|
|
411
|
+
setChecked(undefined);
|
|
412
|
+
refresh(count => count + 1);
|
|
413
|
+
}) : undefined, [rememberAs]);
|
|
382
414
|
const marks = checked?.entries === entries ? checked.marks : rememberAs ? readMarks(rememberAs) : {};
|
|
383
415
|
const reviewed = new Set([...scriptPrints].filter(([key, print]) => marks[key] === print).map(([key]) => key));
|
|
384
|
-
const setReviewed = (key: string, value: boolean) =>
|
|
385
|
-
const current = previous?.entries === entries ? previous.marks : rememberAs ? readMarks(rememberAs) : {};
|
|
416
|
+
const setReviewed = (key: string, value: boolean) => {
|
|
386
417
|
// Keep only marks that still match a script of this review.
|
|
387
418
|
const next: Record<string, string> = {};
|
|
388
|
-
for (const [name, print] of scriptPrints) if (
|
|
419
|
+
for (const [name, print] of scriptPrints) if (marks[name] === print) next[name] = print;
|
|
389
420
|
if (value) next[key] = scriptPrints.get(key)!;
|
|
390
421
|
else delete next[key];
|
|
422
|
+
// Writing tells the tabs of this review, and this panel, which then keeps the marks it has just set.
|
|
391
423
|
if (rememberAs) writeMarks(rememberAs, next);
|
|
392
|
-
|
|
393
|
-
}
|
|
424
|
+
setChecked({entries, marks: next});
|
|
425
|
+
};
|
|
394
426
|
const targets = entries.reduce((total, entry) => total + (entry.comparison?.targets.length ?? 0), 0);
|
|
395
427
|
const scripts = entries.reduce((total, entry) => total + (entry.comparison ? countScripts(entry.comparison) : 0), 0);
|
|
396
428
|
const body = description?.trim();
|
|
@@ -429,3 +461,83 @@ export function ProjectSourceReview({title, description, verification, files, re
|
|
|
429
461
|
</section>)}
|
|
430
462
|
</section>;
|
|
431
463
|
}
|
|
464
|
+
|
|
465
|
+
/** The words of one tab of a project comparison opened as tabs. */
|
|
466
|
+
export function reviewTabWords(locale: string) {
|
|
467
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
468
|
+
return {
|
|
469
|
+
script: (script: ScriptReview) => `${scriptReviewTitle(script, labels)} · ${labels[script.kind]}`,
|
|
470
|
+
target: (target: TargetReview) => target.isStage ? labels.stage : target.name,
|
|
471
|
+
targetDetails: labels.properties,
|
|
472
|
+
project: labels.comparison,
|
|
473
|
+
noChanges: labels.noChanges
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Where a tab keeps its reviewed mark: under `name` with the review of the same name, in the browser when
|
|
479
|
+
* `remember` is set, or else while the page is open.
|
|
480
|
+
*/
|
|
481
|
+
export interface ReviewMarkPlace {
|
|
482
|
+
name: string;
|
|
483
|
+
remember: boolean;
|
|
484
|
+
file: string;
|
|
485
|
+
target: TargetReview;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function useReviewMark(place: ReviewMarkPlace | undefined, script: ScriptReview) {
|
|
489
|
+
const [, refresh] = useState(0);
|
|
490
|
+
useEffect(() => place && subscribeMarks(place.name, () => refresh(count => count + 1)), [place?.name]);
|
|
491
|
+
if (!place) return {};
|
|
492
|
+
const key = reviewKey(place.file, place.target, script);
|
|
493
|
+
const print = scriptPrint(script);
|
|
494
|
+
return {
|
|
495
|
+
reviewed: readMarks(place.name, place.remember)[key] === print,
|
|
496
|
+
setReviewed(value: boolean) {
|
|
497
|
+
const marks = {...readMarks(place.name, place.remember)};
|
|
498
|
+
if (value) marks[key] = print;
|
|
499
|
+
else delete marks[key];
|
|
500
|
+
writeMarks(place.name, marks, place.remember);
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** One script of a comparison, open, for a tab of its own. */
|
|
506
|
+
export function ScriptReviewTab({comparison, script, renderBlock, locale = 'en', loadAsset, locateBlock, mark}: {
|
|
507
|
+
comparison: Comparison; script: ScriptReview; renderBlock: ProjectBlockRenderer; locale?: string;
|
|
508
|
+
loadAsset?: ProjectAssetLoader; locateBlock?: ProjectBlockLocator;
|
|
509
|
+
/** Offers the reviewed mark, shared with the review panel and other tabs that use the same place. */
|
|
510
|
+
mark?: ReviewMarkPlace;
|
|
511
|
+
}) {
|
|
512
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
513
|
+
const location = useBlockLocation(comparison, locateBlock);
|
|
514
|
+
const {reviewed, setReviewed} = useReviewMark(mark, script);
|
|
515
|
+
return <section className="twgit-block-diff twgit-review twgit-review-tab">
|
|
516
|
+
<ScriptComparison script={script} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset} location={location} expanded
|
|
517
|
+
reviewed={reviewed} setReviewed={setReviewed}/>
|
|
518
|
+
<Legend labels={labels}/>
|
|
519
|
+
</section>;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* What changed in a stage or sprite outside its code, or in the project as a whole when no target is given,
|
|
524
|
+
* for a tab of its own.
|
|
525
|
+
*/
|
|
526
|
+
export function TargetReviewTab({comparison, target, locale = 'en', loadAsset}: {
|
|
527
|
+
comparison: Comparison; target?: TargetReview; locale?: string; loadAsset?: ProjectAssetLoader;
|
|
528
|
+
}) {
|
|
529
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
530
|
+
if (!target) {
|
|
531
|
+
return <section className="twgit-block-diff twgit-review twgit-review-tab">
|
|
532
|
+
{comparison.properties.length > 0 ? <p>{labels.properties} · {propertyNames(comparison.properties, labels)}</p> :
|
|
533
|
+
<p>{labels.noChanges}</p>}
|
|
534
|
+
</section>;
|
|
535
|
+
}
|
|
536
|
+
return <section className="twgit-block-diff twgit-review twgit-review-tab">
|
|
537
|
+
<h5><TargetThumbnails target={target} labels={labels} loadAsset={loadAsset}/>{target.isStage ? labels.stage : target.name}</h5>
|
|
538
|
+
{target.status !== 'updated' && <p className="twgit-review-target-status">
|
|
539
|
+
{(target.status === 'added' ? labels.targetAdded : labels.targetRemoved)(target.name, target.isStage)}</p>}
|
|
540
|
+
{target.status === 'updated' && target.properties.length > 0 && <p>{labels.properties} · {propertyNames(target.properties, labels)}</p>}
|
|
541
|
+
{target.scripts.length > 0 && <p>{labels.scriptCount(target.scripts.length)}</p>}
|
|
542
|
+
</section>;
|
|
543
|
+
}
|