@kubohiroya/turbowarp-plus-editor-git 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.ja.md +5 -3
  2. package/README.md +5 -3
  3. package/dist/close-button.d.ts +6 -0
  4. package/dist/file-diff-view.d.ts +3 -1
  5. package/dist/filesystem.d.ts +2 -2
  6. package/dist/history-graph-view.d.ts +40 -0
  7. package/dist/history-graph.d.ts +44 -0
  8. package/dist/index.d.ts +18 -1
  9. package/dist/index.js +697 -31
  10. package/dist/local-operations.d.ts +14 -0
  11. package/dist/messages.d.ts +52 -0
  12. package/dist/{project-comparison-LAyP_beL.js → project-comparison-1rggIJse.js} +240 -20
  13. package/dist/project-comparison.d.ts +1 -0
  14. package/dist/project-source-diff.d.ts +2 -0
  15. package/dist/project-source-diff.js +2 -2
  16. package/dist/project-source-review.d.ts +43 -0
  17. package/dist/project-source-tabs.d.ts +35 -0
  18. package/dist/pull-request-panel.d.ts +6 -1
  19. package/dist/{repository-Cx-mxT9l.js → repository-BYno-5f8.js} +491 -4
  20. package/dist/repository.d.ts +44 -0
  21. package/dist/repository.js +1 -1
  22. package/dist/style.css +31 -4
  23. package/package.json +1 -1
  24. package/src/close-button.tsx +10 -0
  25. package/src/file-diff-view.tsx +7 -3
  26. package/src/filesystem.ts +2 -1
  27. package/src/history-graph-view.tsx +233 -0
  28. package/src/history-graph.ts +90 -0
  29. package/src/index.tsx +137 -17
  30. package/src/local-operations.ts +59 -4
  31. package/src/messages.ts +26 -2
  32. package/src/project-comparison.tsx +4 -2
  33. package/src/project-source-diff.tsx +2 -0
  34. package/src/project-source-review.tsx +127 -15
  35. package/src/project-source-tabs.tsx +118 -0
  36. package/src/pull-request-panel.tsx +26 -5
  37. package/src/repository.ts +155 -1
  38. package/src/style.css +31 -4
package/src/index.tsx CHANGED
@@ -1,10 +1,13 @@
1
1
  import React, {useEffect, useMemo, useRef, useState} from 'react';
2
2
  import {createPortal, render, unmountComponentAtNode} from 'react-dom';
3
+ import {CloseButton} from './close-button.js';
3
4
  import {FileDiffView} from './file-diff-view.js';
4
5
  import {findElement, ProjectChangeListView, ProjectElementView, projectChangeLabels, useProjectChanges} from './project-change-list.js';
5
6
  import type {ChangedFile} from './project-change-model.js';
6
7
  import {openRepository} from './repository.js';
7
- import type {Repository, Snapshot, FileChange, FileDiff, ChangeGroup} from './repository.js';
8
+ import type {Repository, Snapshot, FileChange, FileDiff, ChangeGroup, HistoryCommit, HistoryCursor} from './repository.js';
9
+ import {HistoryGraph} from './history-graph-view.js';
10
+ import type {HistoryActionResult} from './history-graph-view.js';
8
11
  import {labelsFor} from './messages.js';
9
12
  import {GitPanelError} from './filesystem.js';
10
13
  import {ConnectionSettings} from './connection-settings.js';
@@ -12,6 +15,9 @@ import type {GitConnectionSettings} from './connection-settings.js';
12
15
  import type {RemoteOperation} from './remote-operations.js';
13
16
  import type {GitAuthor, LocalOperation} from './local-operations.js';
14
17
  import {PullRequestReviewPanel} from './pull-request-panel.js';
18
+ import {openProjectSourceTabs} from './project-source-tabs.js';
19
+ import type {ProjectSourceTabsFile} from './project-source-tabs.js';
20
+ import {parseProjectSource} from './project-source-patch.js';
15
21
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
16
22
  import type {ScratchBlockLocationWorkspace} from './block-diff-location.js';
17
23
  export {createScratchBlocksDiffLocator} from './block-diff-location.js';
@@ -24,8 +30,14 @@ export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch
24
30
 
25
31
  export {openRepository, createReadOnlyFs, GitPanelError} from './repository.js';
26
32
  export {openProjectComparison} from './project-comparison.js';
33
+ export {openProjectSourceTabs} from './project-source-tabs.js';
34
+ export type {ProjectSourceTabsFile, ProjectSourceTabsOptions} from './project-source-tabs.js';
27
35
  export type {ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
28
- export type {Repository, Snapshot, FileChange, FileDiff} from './repository.js';
36
+ export type {Repository, Snapshot, FileChange, FileDiff, HistoryCommit, HistoryCursor, HistoryPage, HistoryRef} from './repository.js';
37
+ export {HistoryGraph, laneColors} from './history-graph-view.js';
38
+ export type {HistoryActionResult, HistoryGraphProps} from './history-graph-view.js';
39
+ export {layoutHistory} from './history-graph.js';
40
+ export type {GraphCommit, GraphLayout, GraphRow, GraphSegment} from './history-graph.js';
29
41
  export type {GitAuthor, LocalOperation} from './local-operations.js';
30
42
 
31
43
  export interface GitHost {
@@ -55,6 +67,12 @@ export interface GitPanelRequest {
55
67
  filename: string;
56
68
  title?: string;
57
69
  transient?: boolean;
70
+ /** Tabs of one area share a split panel of the host's layout. */
71
+ area?: string;
72
+ /** Gathers the tab with the others of the same `id` under a labelled chip; `color` is a palette id such as `'green'`. */
73
+ tabGroup?: {id: string; label: string; color?: string; collapsed?: boolean};
74
+ /** Opens the tab behind the active one. */
75
+ inactive?: boolean;
58
76
  mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
59
77
  }
60
78
  interface Controller {open(locale: string): void; refresh(automatic?: boolean): Promise<void>}
@@ -112,9 +130,10 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
112
130
  const labels = labelsFor(locale);
113
131
  // A background refresh never drops a user's operation: the operation waits for that refresh instead.
114
132
  const background = useRef<Promise<void> | null>(null);
115
- const run = async (operation: () => Promise<Partial<State>>, automatic = false) => {
133
+ /** Whether an operation ran to the end, or the message the panel shows for why it did not. */
134
+ const run = async (operation: () => Promise<Partial<State>>, automatic = false): Promise<HistoryActionResult> => {
116
135
  if (running.current && !automatic && background.current) await background.current;
117
- if (running.current || !alive.current) return;
136
+ if (running.current || !alive.current) return {done: false};
118
137
  let settle = () => {};
119
138
  if (automatic) background.current = new Promise(resolve => {settle = resolve;});
120
139
  running.current = true;
@@ -126,17 +145,19 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
126
145
  try {
127
146
  const update = await operation();
128
147
  if (epoch.current === ticket) setState(previous => ({...previous, ...update, busy: automatic ? previous.busy : false}));
148
+ return {done: true};
129
149
  } catch (error) {
130
- if (epoch.current !== ticket) return;
150
+ if (epoch.current !== ticket) return {done: false};
131
151
  const code = error instanceof GitPanelError ? error.code : 'failed';
132
152
  // A background read that met another tool still writing tries again on the next check, without an alert.
133
153
  if (automatic && code === 'changed') {
134
154
  handled.current = null;
135
- return;
155
+ return {done: false};
136
156
  }
137
157
  const message = labels[code as keyof typeof labels] || labels.failed;
138
158
  setState(previous => ({...previous, error: automatic && previous.error ? previous.error : message,
139
159
  busy: automatic ? previous.busy : false, diff: null}));
160
+ return {done: false, error: message};
140
161
  } finally {
141
162
  running.current = false;
142
163
  // A result dropped by a newer load still ends the busy state, unless a refresh follows at once.
@@ -347,23 +368,74 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
347
368
  const [message, setMessage] = useState('');
348
369
  const localOperation = (operation: LocalOperation, notice?: string) => {
349
370
  const repository = current.current.repository;
350
- if (!repository || !host.localWrite) return Promise.resolve();
371
+ if (!repository || !host.localWrite) return Promise.resolve<HistoryActionResult>({done: false});
351
372
  return run(async () => {
352
373
  const ticket = epoch.current;
353
374
  await repository.localOperation(operation, () => {
354
375
  if (epoch.current !== ticket) throw new GitPanelError('changed');
355
376
  // Staging records the saved files; edits still in the Editor would be left out without a word.
356
377
  if (operation.kind === 'stage' && host.isProjectDirty()) throw new GitPanelError('saveFirst');
378
+ // Switching replaces the files the Editor opened, like pull.
379
+ if ((operation.kind === 'switchBranch' || (operation.kind === 'createBranch' && operation.checkout)) &&
380
+ (host.isProjectDirty() || host.isProjectRunning?.())) throw new GitPanelError('switchUnsafe');
357
381
  });
358
382
  if (operation.kind === 'commit') setMessage('');
359
383
  const snapshot = await repository.read(current.current.project ?? '');
360
384
  return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null, notice};
361
385
  });
362
386
  };
387
+ const [newBranch, setNewBranch] = useState('');
388
+ const [switchAfterCreate, setSwitchAfterCreate] = useState(true);
389
+ const changeBranch = async (operation: Extract<LocalOperation, {kind: 'createBranch' | 'switchBranch'}>): Promise<HistoryActionResult> => {
390
+ const repository = current.current.repository;
391
+ const project = current.current.project;
392
+ const switching = operation.kind === 'switchBranch' || operation.checkout;
393
+ if (!repository) return {done: false};
394
+ // Tell before switching when the open project folder is not on the branch.
395
+ const at = operation.kind === 'createBranch' ? operation.at : undefined;
396
+ if (switching && project && !(operation.kind === 'createBranch' && !operation.fromRemote && !at) &&
397
+ !await (at ? repository.hasFolder(at, project, 'commit') :
398
+ repository.hasFolder(operation.name, project, operation.kind === 'createBranch' && operation.fromRemote)) &&
399
+ !document.defaultView?.confirm(labels.projectMissing)) return {done: false};
400
+ const result = await localOperation(operation, switching ? labels.switched : labels.branchCreated);
401
+ if (operation.kind === 'createBranch' && !operation.fromRemote && !at) setNewBranch('');
402
+ return result;
403
+ };
404
+ // The history graph reads its own pages after each snapshot, through the same queue as the other reads.
405
+ const [history, setHistory] = useState<{commits: HistoryCommit[]; cursor: HistoryCursor | null; loading: boolean; failed: boolean}>(
406
+ {commits: [], cursor: null, loading: false, failed: false});
407
+ const historyTicket = useRef(0);
408
+ useEffect(() => {
409
+ const ticket = ++historyTicket.current;
410
+ const repository = state.repository;
411
+ if (!repository || !state.snapshot) {
412
+ setHistory({commits: [], cursor: null, loading: false, failed: false});
413
+ return;
414
+ }
415
+ // The rows read before stay until the new page arrives, so focus in the list is kept.
416
+ setHistory(previous => ({...previous, cursor: null, loading: true, failed: false}));
417
+ repository.history().then(page => {
418
+ if (historyTicket.current === ticket) setHistory({commits: page.commits, cursor: page.cursor, loading: false, failed: false});
419
+ }, () => {
420
+ if (historyTicket.current === ticket) setHistory({commits: [], cursor: null, loading: false, failed: true});
421
+ });
422
+ }, [state.repository, state.snapshot]);
423
+ const showMoreHistory = () => {
424
+ const repository = state.repository;
425
+ if (!repository || !history.cursor || history.loading) return;
426
+ const ticket = historyTicket.current;
427
+ setHistory(previous => ({...previous, loading: true}));
428
+ repository.history({after: history.cursor}).then(page => {
429
+ if (historyTicket.current === ticket) setHistory(previous => ({commits: [...previous.commits, ...page.commits],
430
+ cursor: page.cursor, loading: false, failed: false}));
431
+ }, () => {
432
+ if (historyTicket.current === ticket) setHistory(previous => ({...previous, cursor: null, loading: false, failed: true}));
433
+ });
434
+ };
363
435
  const focusTrap = (event: React.KeyboardEvent<HTMLDivElement>) => {
364
436
  if (event.key === 'Escape') {event.stopPropagation(); closePanel(); return;}
365
437
  if (event.key !== 'Tab') return;
366
- const items = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]')]
438
+ const items = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled):not([tabindex="-1"]), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]')]
367
439
  .filter(item => item.getClientRects().length > 0);
368
440
  const first = items[0], last = items[items.length - 1];
369
441
  if (event.shiftKey && (document.activeElement === first || document.activeElement === event.currentTarget)) {
@@ -375,6 +447,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
375
447
  state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
376
448
  if (!shown) return null;
377
449
  const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
450
+ // Branches can be switched and created whenever writes are allowed and the repository has been read.
451
+ const branching = !!host.localWrite && !!state.snapshot;
378
452
  // Writing works on the current changes, not on a record from the history, and only on a branch.
379
453
  const writable = !!host.localWrite && !state.commit && !!state.snapshot?.branch;
380
454
  const unrecorded = [...new Set(state.changes.filter(change => change.group === 'unstaged' || change.group === 'untracked')
@@ -390,6 +464,20 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
390
464
  const reviewLocator = (path: string, headSha: string) => locator && path === projectFile && state.snapshot?.head === headSha &&
391
465
  !state.changes.length ? locator : undefined;
392
466
  const selection = projectList && !state.selected ? findElement(projectList.list, element) : null;
467
+ // Only a tab leaves room for more tabs beside it; the modal dialog keeps its comparisons inside.
468
+ const openTabs = slot && host.openPanel && host.renderReviewBlock ?
469
+ (key: string, files: readonly ProjectSourceTabsFile[], rememberAs?: string) =>
470
+ openProjectSourceTabs(host.openPanel, {key, files, renderBlock: host.renderReviewBlock!, locale, rememberAs}) : undefined;
471
+ const openFileTabs = (change: FileChange) => openTabs && ((before: string | null, after: string | null) => {
472
+ let files: ProjectSourceTabsFile[];
473
+ try {
474
+ files = [{path: change.path, before: parseProjectSource(before), after: parseProjectSource(after),
475
+ loadAsset: loadAsset ?? undefined, locateBlock: fileLocator(change)}];
476
+ } catch {
477
+ return;
478
+ }
479
+ openTabs(`${state.commit ?? change.group}:${change.path}`, files);
480
+ });
393
481
  const content = <>
394
482
  <header>
395
483
  <h2>{labels.title}</h2>
@@ -398,7 +486,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
398
486
  {!review && state.project !== null && <button disabled={state.busy} onClick={() => void openProject(state.project!)}>{labels.reload}</button>}
399
487
  {state.repository && host.renderReviewBlock && <button type="button" disabled={state.busy}
400
488
  onClick={() => setReview(!review)}>{review ? labels.localChanges : labels.pullRequests}</button>}
401
- <button onClick={closePanel}>{labels.close}</button>
489
+ <CloseButton label={labels.close} onClick={closePanel}/>
402
490
  </header>
403
491
  <div className="twgit-context">
404
492
  <span>{labels.repository} · {state.repository?.root.name || '—'}</span>
@@ -429,7 +517,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
429
517
  {!state.repository && <p>{labels.empty}</p>}
430
518
  {review && state.repository && host.renderReviewBlock &&
431
519
  <PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}
432
- locateFor={reviewLocator}/>}
520
+ locateFor={reviewLocator} openTabs={openTabs}/>}
433
521
  {!review && state.repository && <div className="twgit-content">
434
522
  <aside>
435
523
  {state.browser && <details>
@@ -446,7 +534,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
446
534
  {(() => {
447
535
  const fileList = groups.map(group => <section key={group}>
448
536
  <h3>{labels[group]} · {state.changes.filter(change => change.group === group).length}</h3>
449
- <ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}>
537
+ <ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}
538
+ className={writable ? 'twgit-file-row' : undefined}>
450
539
  <button className="twgit-file" data-git-group={group} disabled={state.busy}
451
540
  aria-pressed={state.selected === change} onClick={() => openFile(change)}>
452
541
  <small className="twgit-change-kind">{!change.after ? labels.deleted : !change.before ? labels.added : labels.modified}</small> {change.path}
@@ -471,19 +560,50 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
471
560
  onClick={() => void localOperation({kind: 'commit', message, author: host.localWrite?.author?.()},
472
561
  labels.committed)}>{labels.commitButton}</button>
473
562
  </section>}
563
+ {branching && state.snapshot && <section className="twgit-branches" aria-label={labels.branches}>
564
+ <h3>{labels.branches}</h3>
565
+ <ul>{state.snapshot.branches.map(name => <li key={name}><code>{name}</code>
566
+ {name === state.snapshot!.branch ? <small> · {labels.currentBranch}</small> :
567
+ <button type="button" disabled={state.busy} aria-label={`${labels.switchTo}: ${name}`}
568
+ onClick={() => void changeBranch({kind: 'switchBranch', name})}>{labels.switchTo}</button>}
569
+ </li>)}</ul>
570
+ {state.snapshot.head && <form onSubmit={event => {
571
+ event.preventDefault();
572
+ if (newBranch.trim()) void changeBranch({kind: 'createBranch', name: newBranch.trim(), checkout: switchAfterCreate});
573
+ }}>
574
+ <label>{labels.newBranch} <input value={newBranch} disabled={state.busy}
575
+ onChange={event => setNewBranch(event.currentTarget.value)}/></label>
576
+ <label><input type="checkbox" checked={switchAfterCreate} disabled={state.busy}
577
+ onChange={event => setSwitchAfterCreate(event.currentTarget.checked)}/> {labels.switchAfterCreate}</label>
578
+ <button type="submit" disabled={state.busy || !newBranch.trim()}>{labels.createBranch}</button>
579
+ </form>}
580
+ {state.snapshot.remoteBranches.length > 0 && <>
581
+ <h3>{labels.remoteBranches}</h3>
582
+ <ul>{state.snapshot.remoteBranches.map(name => <li key={name}><code>origin/{name}</code>
583
+ <button type="button" disabled={state.busy} aria-label={`${labels.createFromRemote}: origin/${name}`}
584
+ onClick={() => void changeBranch({kind: 'createBranch', name, fromRemote: true, checkout: true})}>
585
+ {labels.createFromRemote}</button></li>)}</ul>
586
+ </>}
587
+ </section>}
474
588
  <h3>{labels.history}</h3>
475
- <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>
589
+ {state.snapshot && <HistoryGraph commits={history.commits} selected={state.commit} locale={locale} busy={state.busy}
590
+ onSelect={oid => void selectCommit(oid)} hasMore={!!history.cursor} loadingMore={history.loading}
591
+ onShowMore={showMoreHistory} localBranches={state.snapshot.branches}
592
+ {...branching ? {
593
+ onCreateBranch: (oid: string, name: string, checkout: boolean) =>
594
+ changeBranch({kind: 'createBranch', name, at: oid, checkout}),
595
+ onSwitchBranch: (name: string) => changeBranch({kind: 'switchBranch', name}),
596
+ onCreateFromRemote: (name: string) => changeBranch({kind: 'createBranch', name, fromRemote: true, checkout: true})
597
+ } : {}}/>}
598
+ {history.loading && !history.commits.length && <p>{labels.historyLoading}</p>}
599
+ {history.failed && <p>{labels.historyFailed}</p>}
480
600
  </aside>
481
601
  <main className="twgit-diff">
482
602
  {selection && projectList ? <ProjectElementView changes={projectList} selection={selection} locale={locale}
483
603
  locateBlock={state.commit ? undefined : locator}
484
604
  renderBlock={host.renderReviewBlock} onSelectFile={openFile}/> :
485
605
  state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
486
- locateBlock={fileLocator(state.selected)}
606
+ locateBlock={fileLocator(state.selected)} onOpenTabs={openFileTabs(state.selected)}
487
607
  renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
488
608
  </main>
489
609
  </div>}
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import React, {useEffect, useRef} from 'react';
2
+ import {CloseButton} from './close-button.js';
2
3
  import {render, unmountComponentAtNode} from 'react-dom';
3
4
  import {ProjectSourceComparison} from './project-source-review.js';
4
5
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
@@ -21,6 +22,7 @@ export interface ProjectComparisonOptions {
21
22
  sections: readonly ProjectComparisonSection[];
22
23
  /** The first action is the one the dialog focuses. Closing the dialog or pressing Escape chooses none. */
23
24
  actions: readonly ProjectComparisonAction[];
25
+ /** The tooltip and accessible name of the X button that closes the dialog. By default, "Close" in the locale. */
24
26
  closeLabel?: string;
25
27
  renderBlock: ProjectBlockRenderer;
26
28
  locale?: string;
@@ -32,7 +34,7 @@ export interface ProjectComparisonDialogProps extends ProjectComparisonOptions {
32
34
  const focusable = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]';
33
35
 
34
36
  /** A modal that lays project comparisons side by side in sections and asks the user to choose an action. */
35
- export function ProjectComparisonDialog({title, description, notes = [], sections, actions, closeLabel = 'Close',
37
+ export function ProjectComparisonDialog({title, description, notes = [], sections, actions, closeLabel,
36
38
  renderBlock, locale = 'en', onChoose}: ProjectComparisonDialogProps) {
37
39
  const dialog = useRef<HTMLDivElement>(null);
38
40
  const first = useRef<HTMLButtonElement>(null);
@@ -55,7 +57,7 @@ export function ProjectComparisonDialog({title, description, notes = [], section
55
57
  aria-label={title} onKeyDown={keyDown}>
56
58
  <header>
57
59
  <h2>{title}</h2>
58
- <button type="button" onClick={() => onChoose(null)}>{closeLabel}</button>
60
+ <CloseButton label={closeLabel ?? (locale.startsWith('ja') ? '閉じる' : 'Close')} onClick={() => onChoose(null)}/>
59
61
  </header>
60
62
  <div className="twgit-comparison-body">
61
63
  {description && <p>{description}</p>}
@@ -10,6 +10,8 @@ import './project-source-diff.css';
10
10
  export {createGitHubPullRequests, gitHubRepositoryFromOrigin} from './github-pull-requests.js';
11
11
  export {ProjectSourceComparison, ProjectSourceReview} from './project-source-review.js';
12
12
  export {ProjectComparisonDialog, openProjectComparison} from './project-comparison.js';
13
+ export {openProjectSourceTabs} from './project-source-tabs.js';
14
+ export type {ProjectSourceTabsFile, ProjectSourceTabsOptions} from './project-source-tabs.js';
13
15
  export type {ProjectComparisonAction, ProjectComparisonDialogProps, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
14
16
  export type {ProjectSourceComparisonProps, ProjectSourceReviewFile, ProjectSourceReviewProps} from './project-source-review.js';
15
17