@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/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
- const run = async (operation: () => Promise<Partial<State>>, automatic = false) => {
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
- <ol className="twgit-history">{state.snapshot?.commits.map(commit => <li key={commit.oid}>
476
- <button disabled={state.busy} aria-pressed={state.commit === commit.oid} title={commit.oid}
477
- onClick={() => void selectCommit(commit.oid)}>{commit.message.split('\n')[0]}</button>
478
- <small>{commit.author} · {new Date(commit.timestamp * 1000).toLocaleString(locale)}</small>
479
- </li>)}</ol>
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>}
@@ -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 transaction = createMutationFs(root, false);
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('changed');
44
+ if (await exists(path)) throw new GitPanelError('locked');
38
45
  }
39
- if (operation.kind === 'stage') {
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
- function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset, location}: {
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(false);
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 beforeTitle = script.before[0] && scriptTitle(script.before[0], labels);
137
- const afterTitle = script.after[0] && scriptTitle(script.after[0], labels);
138
- const title = beforeTitle && afterTitle && beforeTitle !== afterTitle ? beforeTitle + ' → ' + afterTitle : afterTitle ?? beforeTitle;
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) => setChecked(previous => {
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 (current[name] === print) next[name] = print;
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
- return {entries, marks: next};
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
+ }