@kubohiroya/turbowarp-plus-editor-git 0.9.0 → 0.11.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.
@@ -5,6 +5,7 @@ import type {FileChange, FileDiff} from './repository.js';
5
5
  import type {labelsFor} from './messages.js';
6
6
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
7
7
  import type {ProjectAssetLoader} from './project-assets.js';
8
+ import type {ProjectBlockLocator} from './block-diff-location.js';
8
9
 
9
10
  const en = {view: 'Show changes as', blocks: 'Blocks', text: 'Text'};
10
11
  const ja: typeof en = {view: '変更の表示', blocks: 'ブロック', text: 'テキスト'};
@@ -15,16 +16,18 @@ export const isProjectSource = (path: string) => path.split('/').pop() === 'proj
15
16
  /**
16
17
  * Shows one changed file. Project sources are compared as blocks by default, with their text diff one switch away.
17
18
  */
18
- export function FileDiffView({change, diff, labels, locale, renderBlock, loadAsset}: {
19
+ export function FileDiffView({change, diff, labels, locale, renderBlock, loadAsset, locateBlock}: {
19
20
  change: FileChange; diff: FileDiff | null; labels: ReturnType<typeof labelsFor>; locale: string;
20
- renderBlock?: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
21
+ renderBlock?: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader; locateBlock?: ProjectBlockLocator;
21
22
  }) {
22
23
  const [view, setView] = useState<'blocks' | 'text'>('blocks');
23
24
  const names = locale.startsWith('ja') ? ja : en;
24
25
  const parsed = diff?.patch ? parseDiff(diff.patch)[0] : null;
25
26
  const text = <>
26
- <p>{labels.mode} · {change.oldMode?.toString(8) || '—'} → {change.newMode?.toString(8) || '—'}</p>
27
- {diff && <p>{diff.beforeSize} → {diff.afterSize} {labels.bytes}</p>}
27
+ <details className="twgit-file-details"><summary>{labels.details}</summary>
28
+ <p>{labels.mode} · {change.oldMode?.toString(8) || '—'} → {change.newMode?.toString(8) || '—'}</p>
29
+ {diff && <p>{diff.beforeSize} → {diff.afterSize} {labels.bytes}</p>}
30
+ </details>
28
31
  {diff?.binary && <p>{labels.binary}</p>}
29
32
  {diff?.tooLarge && <p>{labels.large}</p>}
30
33
  {parsed && <Diff viewType="unified" diffType={parsed.type} hunks={parsed.hunks}>
@@ -38,7 +41,7 @@ export function FileDiffView({change, diff, labels, locale, renderBlock, loadAss
38
41
  {(['blocks', 'text'] as const).map(mode => <button key={mode} type="button" aria-pressed={view === mode}
39
42
  data-git-view={mode} onClick={() => setView(mode)}>{names[mode]}</button>)}
40
43
  </div>}
41
- {sources && view === 'blocks' ? <ProjectSourceComparison renderBlock={renderBlock!} locale={locale} fallback={text} loadAsset={loadAsset}
44
+ {sources && view === 'blocks' ? <ProjectSourceComparison renderBlock={renderBlock!} locale={locale} fallback={text} loadAsset={loadAsset} locateBlock={locateBlock}
42
45
  before={change.before === null ? null : sources.before} after={change.after === null ? null : sources.after}/> : text}
43
46
  </>;
44
47
  }
package/src/index.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import React, {useEffect, useMemo, useRef, useState} from 'react';
2
- import {render, unmountComponentAtNode} from 'react-dom';
2
+ import {createPortal, render, unmountComponentAtNode} from 'react-dom';
3
3
  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';
@@ -12,6 +12,11 @@ import type {GitConnectionSettings} from './connection-settings.js';
12
12
  import type {RemoteOperation} from './remote-operations.js';
13
13
  import {PullRequestReviewPanel} from './pull-request-panel.js';
14
14
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
15
+ import type {ScratchBlockLocationWorkspace} from './block-diff-location.js';
16
+ export {createScratchBlocksDiffLocator} from './block-diff-location.js';
17
+ export {createEditorBlockLocator} from './editor-block-locator.js';
18
+ import {createEditorBlockLocator} from './editor-block-locator.js';
19
+ import type {LocatorVM} from './editor-block-locator.js';
15
20
 
16
21
  export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
17
22
  export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch-block-diff-renderer.js';
@@ -26,6 +31,22 @@ export interface GitHost {
26
31
  isProjectDirty(): boolean;
27
32
  isProjectRunning?(): boolean;
28
33
  subscribe?(listener: (event: 'loaded' | 'changed') => void): () => void;
34
+ /**
35
+ * Opens the panel as a tab of the host's layout, with the same shape as the layout package's DocumentPanelRequest.
36
+ * Returning `false` keeps the modal dialog.
37
+ */
38
+ openPanel?(request: GitPanelRequest): boolean;
39
+ /** Opens the code of one stage or sprite, so a diff can point at its block. Without it, no block is located. */
40
+ openTargetCode?(targetId: string): void | Promise<void>;
41
+ /** The Editor's code workspace as it is now. */
42
+ getWorkspace?(): ScratchBlockLocationWorkspace | null | Promise<ScratchBlockLocationWorkspace | null>;
43
+ }
44
+ export interface GitPanelRequest {
45
+ reference: {providerId: string; documentId: string};
46
+ filename: string;
47
+ title?: string;
48
+ transient?: boolean;
49
+ mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
29
50
  }
30
51
  interface Controller {open(locale: string): void; refresh(automatic?: boolean): Promise<void>}
31
52
  interface State {
@@ -51,8 +72,14 @@ type ObserverWindow = Window & {FileSystemObserver?: new (callback: () => void)
51
72
  }};
52
73
  const watchInterval = 2000;
53
74
 
54
- function GitPanel({document, host, controller}: {document: Document; host: GitHost; controller: Controller}) {
75
+ function GitPanel({vm, document, host, controller}: {vm: object; document: Document; host: GitHost; controller: Controller}) {
55
76
  const [visible, setVisible] = useState(false);
77
+ // A layout tab that holds the panel. The panel component stays mounted, so the repository survives closing the tab.
78
+ const [slot, setSlot] = useState<{element: HTMLElement; close(): void} | null>(null);
79
+ const shown = visible || slot !== null;
80
+ // Only a tab leaves the Editor visible, so only a tab offers to point at blocks in it.
81
+ const locator = useMemo(() => slot && host.openTargetCode && host.getWorkspace ? createEditorBlockLocator({
82
+ vm: vm as LocatorVM, openTargetCode: host.openTargetCode, getWorkspace: host.getWorkspace}) : undefined, [slot !== null, host, vm]);
56
83
  const [review, setReview] = useState(false);
57
84
  const [locale, setLocale] = useState(document.documentElement.lang || 'en');
58
85
  const [dirty, setDirty] = useState(host.isProjectDirty());
@@ -99,7 +126,21 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
99
126
  }
100
127
  }
101
128
  };
102
- controller.open = language => {setLocale(language); setVisible(true);};
129
+ controller.open = language => {
130
+ setLocale(language);
131
+ const opened = host.openPanel?.({reference: {providerId: 'turbowarp-plus-git', documentId: 'local-git'},
132
+ filename: 'Git', title: labelsFor(language).title, transient: true,
133
+ mount(element, close) {
134
+ setVisible(false);
135
+ setSlot({element, close});
136
+ return {requestClose: close, dispose() {setSlot(previous => previous?.element === element ? null : previous);}};
137
+ }});
138
+ if (!opened) setVisible(true);
139
+ };
140
+ const closePanel = () => {
141
+ if (slot) slot.close();
142
+ else setVisible(false);
143
+ };
103
144
  controller.refresh = async (automatic = false) => {
104
145
  if (!alive.current || !current.current.repository) return;
105
146
  if (running.current) {
@@ -128,7 +169,7 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
128
169
  // While the panel is open, notice Git commands, agents and editors that change the repository outside the Editor.
129
170
  useEffect(() => {
130
171
  const repository = state.repository;
131
- if (!visible || !repository) return;
172
+ if (!shown || !repository) return;
132
173
  const view = document.defaultView as ObserverWindow;
133
174
  let stopped = false;
134
175
  let checking = false;
@@ -177,13 +218,13 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
177
218
  document.removeEventListener('visibilitychange', onFocus);
178
219
  observer?.disconnect();
179
220
  };
180
- }, [visible, state.repository, locale]);
221
+ }, [shown, state.repository, locale]);
181
222
  useEffect(() => {
182
- if (!visible) return;
223
+ if (!visible || slot) return;
183
224
  const previous = document.activeElement as HTMLElement | null;
184
225
  dialog.current?.focus();
185
226
  return () => {if (previous?.isConnected) previous.focus();};
186
- }, [visible]);
227
+ }, [visible, slot]);
187
228
  const chooseRoot = async () => {
188
229
  const view = document.defaultView as PickerWindow;
189
230
  await run(async () => {
@@ -208,6 +249,8 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
208
249
  const result = await host.openProject(directory, () => document.defaultView!.confirm(labels.replace));
209
250
  if (result === 'cancelled') return {};
210
251
  } finally {opening.current = false;}
252
+ // Loading the project brings its code to the front; keep the panel the person is working in in front of it.
253
+ if (slot) controller.open(locale);
211
254
  if (!alive.current) return {};
212
255
  setDirty(host.isProjectDirty());
213
256
  setState(previous => ({...previous, project: path, snapshot: null, changes: [], selected: null, diff: null, commit: null}));
@@ -256,7 +299,7 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
256
299
  });
257
300
  };
258
301
  const focusTrap = (event: React.KeyboardEvent<HTMLDivElement>) => {
259
- if (event.key === 'Escape') {event.stopPropagation(); setVisible(false); return;}
302
+ if (event.key === 'Escape') {event.stopPropagation(); closePanel(); return;}
260
303
  if (event.key !== 'Tab') return;
261
304
  const items = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]')]
262
305
  .filter(item => item.getClientRects().length > 0);
@@ -268,13 +311,19 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
268
311
  // One loader per selected change, so thumbnails read each asset once.
269
312
  const loadAsset = useMemo(() => state.repository && state.snapshot && state.selected ?
270
313
  state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
271
- if (!visible) return null;
314
+ if (!shown) return null;
272
315
  const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
273
316
  const projectList = typeof projectChanges === 'object' ? projectChanges : null;
317
+ // The open project matches the working tree, so only the working-tree side of its project file can be located.
318
+ const projectFile = state.project === null ? null : [state.project, 'project.source.json'].filter(Boolean).join('/');
319
+ const fileLocator = (change: FileChange) => locator && change.path === projectFile &&
320
+ (change.group === 'unstaged' || change.group === 'untracked' ||
321
+ (change.group === 'staged' && !state.changes.some(item => item.path === change.path && item.group === 'unstaged'))) ?
322
+ locator : undefined;
323
+ const reviewLocator = (path: string, headSha: string) => locator && path === projectFile && state.snapshot?.head === headSha &&
324
+ !state.changes.length ? locator : undefined;
274
325
  const selection = projectList && !state.selected ? findElement(projectList.list, element) : null;
275
- return <div className="twgit-backdrop">
276
- <div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
277
- onKeyDown={focusTrap}>
326
+ const content = <>
278
327
  <header>
279
328
  <h2>{labels.title}</h2>
280
329
  <button onClick={chooseRoot} disabled={state.busy}>{labels.choose}</button>
@@ -282,28 +331,38 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
282
331
  {!review && state.project !== null && <button disabled={state.busy} onClick={() => void openProject(state.project!)}>{labels.reload}</button>}
283
332
  {state.repository && host.renderReviewBlock && <button type="button" disabled={state.busy}
284
333
  onClick={() => setReview(!review)}>{review ? labels.localChanges : labels.pullRequests}</button>}
285
- <button onClick={() => setVisible(false)}>{labels.close}</button>
334
+ <button onClick={closePanel}>{labels.close}</button>
286
335
  </header>
287
336
  <div className="twgit-context">
288
337
  <span>{labels.repository} · {state.repository?.root.name || '—'}</span>
289
338
  <span>{labels.project} · {state.project === null ? labels.none : state.project || labels.root}</span>
290
- <strong>{state.snapshot?.branch || (state.snapshot?.head ? labels.detached : labels.unborn)}</strong>
291
- <code>{state.snapshot?.head?.slice(0, 12)}</code>
292
339
  <span data-git-dirty="">{dirty ? labels.dirty : labels.saved}</span>
340
+ {state.snapshot && <span data-git-upstream={state.snapshot.upstream.state} title={labels.checkedAt}>{upstreamText(state.snapshot, labels)}</span>}
341
+ {!review && host.connectionSettings && <ConnectionSettings settings={host.connectionSettings} locale={locale}
342
+ runOperation={host.connectionSettings.getSession ? remoteOperation : undefined}
343
+ readOrigin={state.repository && !state.busy ? async () => {
344
+ const ticket = epoch.current;
345
+ const remote = await state.repository!.readOrigin();
346
+ return epoch.current === ticket ? remote : undefined;
347
+ } : undefined}/>}
348
+ {state.snapshot && <details className="twgit-git-details">
349
+ <summary>{labels.details}</summary>
350
+ <div className="twgit-connection-body">
351
+ <p>{labels.branch} · <strong>{state.snapshot.branch || (state.snapshot.head ? labels.detached : labels.unborn)}</strong></p>
352
+ <p>{labels.head} · <code>{state.snapshot.head?.slice(0, 12) ?? '—'}</code></p>
353
+ <p>{labels.branches} · {state.snapshot.branches.join(', ') || '—'}</p>
354
+ <p>{labels.groups} · {labels.gitStaged} = {labels.staged} / {labels.gitUnstaged} = {labels.unstaged} / {labels.gitUntracked} = {labels.untracked}</p>
355
+ <p>{labels.permissions}</p>
356
+ </div>
357
+ </details>}
293
358
  </div>
294
- {!review && host.connectionSettings && <ConnectionSettings settings={host.connectionSettings} locale={locale}
295
- runOperation={host.connectionSettings.getSession ? remoteOperation : undefined}
296
- readOrigin={state.repository && !state.busy ? async () => {
297
- const ticket = epoch.current;
298
- const remote = await state.repository!.readOrigin();
299
- return epoch.current === ticket ? remote : undefined;
300
- } : undefined}/>}
301
359
  {state.error && <p className="twgit-error" role="alert">{state.error}</p>}
302
360
  {state.notice && <p className="twgit-context" role="status">{state.notice}</p>}
303
361
  {state.busy && <p role="status">{labels.loading}</p>}
304
362
  {!state.repository && <p>{labels.empty}</p>}
305
363
  {review && state.repository && host.renderReviewBlock &&
306
- <PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}/>}
364
+ <PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}
365
+ locateFor={reviewLocator}/>}
307
366
  {!review && state.repository && <div className="twgit-content">
308
367
  <aside>
309
368
  {state.browser && <details>
@@ -323,7 +382,7 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
323
382
  <ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}>
324
383
  <button className="twgit-file" data-git-group={group} disabled={state.busy}
325
384
  aria-pressed={state.selected === change} onClick={() => openFile(change)}>
326
- <span>{!change.after ? 'D' : !change.before ? 'A' : 'M'}</span> {change.path}
385
+ <small className="twgit-change-kind">{!change.after ? labels.deleted : !change.before ? labels.added : labels.modified}</small> {change.path}
327
386
  </button>
328
387
  </li>)}</ul>
329
388
  </section>);
@@ -332,33 +391,50 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
332
391
  fileListOpen={fileListOpen} setFileListOpen={setFileListOpen}/> : fileList;
333
392
  })()}
334
393
  {!state.changes.length && <p>{labels.clean}</p>}
335
- <details><summary>{labels.branches}</summary><ul>{state.snapshot?.branches.map(branch => <li key={branch}>{branch}</li>)}</ul></details>
336
394
  <h3>{labels.history}</h3>
337
395
  <ol className="twgit-history">{state.snapshot?.commits.map(commit => <li key={commit.oid}>
338
396
  <button disabled={state.busy} aria-pressed={state.commit === commit.oid} title={commit.oid}
339
397
  onClick={() => void selectCommit(commit.oid)}>{commit.message.split('\n')[0]}</button>
340
- <small><code>{commit.oid.slice(0, 8)}</code> · {commit.author} · {new Date(commit.timestamp * 1000).toLocaleString(locale)}</small>
398
+ <small>{commit.author} · {new Date(commit.timestamp * 1000).toLocaleString(locale)}</small>
341
399
  </li>)}</ol>
342
400
  </aside>
343
401
  <main className="twgit-diff">
344
402
  {selection && projectList ? <ProjectElementView changes={projectList} selection={selection} locale={locale}
403
+ locateBlock={state.commit ? undefined : locator}
345
404
  renderBlock={host.renderReviewBlock} onSelectFile={openFile}/> :
346
405
  state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
406
+ locateBlock={fileLocator(state.selected)}
347
407
  renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
348
408
  </main>
349
409
  </div>}
350
- <footer>{labels.permissions}</footer>
351
- </div>
410
+ </>;
411
+ // In a tab the Editor stays usable beside the panel, so there is no backdrop, focus trap or Escape to close.
412
+ if (slot) return createPortal(<div className="twgit-panel twgit-panel-tab" role="region" aria-label={labels.title}>{content}</div>,
413
+ slot.element);
414
+ return <div className="twgit-backdrop">
415
+ <div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
416
+ onKeyDown={focusTrap}>{content}</div>
352
417
  </div>;
353
418
  }
354
419
 
420
+ /** The GitHub relation in plain words, with the number of records where it helps. */
421
+ function upstreamText({upstream}: Snapshot, labels: ReturnType<typeof labelsFor>) {
422
+ switch (upstream.state) {
423
+ case 'same': return labels.upstreamSame;
424
+ case 'ahead': return `${labels.upstreamAhead} · ${upstream.ahead} ${labels.records}`;
425
+ case 'behind': return `${labels.upstreamBehind} · ${upstream.behind} ${labels.records}`;
426
+ case 'diverged': return `${labels.upstreamDiverged} · +${upstream.ahead} / −${upstream.behind}`;
427
+ default: return labels.upstreamUnknown;
428
+ }
429
+ }
430
+
355
431
  export function mountGitPanel({vm, document, host}: {vm: object; document: Document; host: GitHost}) {
356
432
  if (!(document.defaultView as PickerWindow)?.showDirectoryPicker) return null;
357
433
  const root = document.createElement('div');
358
434
  document.body.append(root);
359
435
  const controller: Controller = {open() {}, async refresh() {}};
360
436
  controllers.set(vm, controller);
361
- render(<GitPanel document={document} host={host} controller={controller}/>, root);
437
+ render(<GitPanel vm={vm} document={document} host={host} controller={controller}/>, root);
362
438
  return {refresh: () => controller.refresh(), dispose() {
363
439
  if (controllers.get(vm) === controller) controllers.delete(vm);
364
440
  void host.connectionSettings?.disconnect().catch(() => {});
package/src/messages.ts CHANGED
@@ -7,9 +7,9 @@ export const messages = {
7
7
  open: 'Open This Project', reload: 'Reload Project', up: 'Parent folder', browse: 'Choose a project inside this repository',
8
8
  replace: 'Replace the open project and discard its unsaved changes?',
9
9
  dirty: 'The Editor has unsaved changes.', saved: 'The Editor has no unsaved changes.',
10
- staged: 'Staged', unstaged: 'Unstaged', untracked: 'Untracked', commit: 'Commit changes',
11
- history: 'History', worktree: 'Working tree', clean: 'No changes in the selected folder.',
12
- branches: 'Local branches', unborn: 'No commits yet', detached: 'Detached HEAD', select: 'Select a file to view its diff.',
10
+ staged: 'Changes in the next record', unstaged: 'Saved, not yet recorded', untracked: 'New files never recorded', commit: 'Changes in this record',
11
+ history: 'Records (commits)', worktree: 'Saved files', clean: 'No saved changes in the selected folder.',
12
+ branches: 'Local branches', unborn: 'No records yet', detached: 'Detached HEAD', select: 'Select a file to view its diff.',
13
13
  binary: 'Binary asset', large: 'Text preview exceeds its display limit.', bytes: 'bytes',
14
14
  mode: 'Git file mode', permissions: 'Working-tree executable permission changes are unavailable in the browser.',
15
15
  unchanged: 'The text is unchanged.', loading: 'Reading repository…',
@@ -17,16 +17,20 @@ export const messages = {
17
17
  changed: 'Files or Git state changed while reading. Refresh to read the current state.',
18
18
  outdated: 'The repository changed outside the Editor. Refresh to read the current state.',
19
19
  unsupported: 'This repository contains an entry the browser cannot represent.',
20
- pullUnsafe: 'Stop the project and clear unsaved or local Git changes before pulling.',
21
- diverged: 'Pull requires a fast-forward. Your local branch and files are intact.',
20
+ pullUnsafe: 'Stop the project, and save or undo unsaved edits and unrecorded changes, before getting changes from GitHub (pull).',
21
+ diverged: 'This branch and GitHub\'s have both gained records, so they cannot be combined here (pull only fast-forwards). Your branch and files are unchanged.',
22
22
  session: 'Reconnect GitHub for this repository and requested permission.',
23
- pushUnknown: 'The push result is unknown. Fetch and check the remote branch before retrying.',
24
- pushRefresh: 'The push succeeded. Fetch to refresh the local tracking branch.',
25
- rollbackFailed: 'Git could not restore all local changes. Check the working tree and Git status.',
26
- remoteFailed: 'The Git operation failed. Check the selected connection and retry.',
27
- remoteDone: 'Git operation completed.',
28
- pulledProject: 'Pull completed. Use Reload Project to open the updated files.',
29
- confirmPush: 'Push the current branch to its GitHub origin?',
23
+ pushUnknown: 'It is unknown whether GitHub received the records. Check GitHub (fetch) before sending again.',
24
+ pushRefresh: 'GitHub received the records. Check GitHub (fetch) to update the status.',
25
+ rollbackFailed: 'Some local changes could not be restored. Check the files and the Git details.',
26
+ remoteFailed: 'Could not complete the GitHub operation. Check the connection and try again.',
27
+ remoteDone: 'The GitHub operation finished.',
28
+ pulledProject: 'Got the changes from GitHub. Use Reload Project to open the updated files.',
29
+ confirmPush: 'Send the records on this branch to GitHub (push)?',
30
+ details: 'Git details', branch: 'Branch', head: 'HEAD', groups: 'Git groups', added: 'Added', modified: 'Changed', deleted: 'Deleted',
31
+ gitStaged: 'Staged', gitUnstaged: 'Unstaged', gitUntracked: 'Untracked',
32
+ upstreamSame: 'Same as GitHub', upstreamAhead: 'Ahead of GitHub', upstreamBehind: 'Behind GitHub', upstreamDiverged: 'Diverged from GitHub',
33
+ upstreamUnknown: 'Not checked with GitHub yet', records: 'records', checkedAt: 'as of the last check',
30
34
  failed: 'Could not read the repository. Check folder access and refresh.'
31
35
  },
32
36
  ja: {
@@ -35,11 +39,11 @@ export const messages = {
35
39
  empty: '既存の Git リポジトリを選び、ファイルの状態と履歴を確認できます。',
36
40
  repository: 'リポジトリ', project: '作品フォルダ', none: '作品は未選択', root: 'リポジトリルート',
37
41
  open: 'この作品を開く', reload: '作品を再読込', up: '上のフォルダ', browse: 'リポジトリ内の作品を選択',
38
- replace: '未保存の変更を破棄して、別の作品を開きますか?',
39
- dirty: 'Editor に未保存の変更があります。', saved: 'Editor に未保存の変更はありません。',
40
- staged: 'ステージ済み', unstaged: '未ステージ', untracked: '未追跡', commit: 'コミットの変更',
41
- history: '履歴', worktree: '作業ツリー', clean: '対象フォルダに変更はありません。',
42
- branches: 'ローカルブランチ', unborn: 'コミットはまだありません', detached: 'ブランチ未選択の HEAD', select: 'ファイルを選ぶと差分を表示します。',
42
+ replace: '保存していない編集を破棄して、別の作品を開きますか?',
43
+ dirty: 'Editor に保存していない編集があります。', saved: 'Editor に保存していない編集はありません。',
44
+ staged: '次の記録に入る変更', unstaged: '保存したが、まだ記録していない変更', untracked: 'まだ記録したことのない新しいファイル', commit: 'この記録の変更',
45
+ history: '記録(コミット)の履歴', worktree: '保存したファイル', clean: '対象フォルダに、保存した変更はありません。',
46
+ branches: 'ローカルブランチ', unborn: 'まだ記録がありません', detached: 'ブランチ未選択の HEAD', select: 'ファイルを選ぶと差分を表示します。',
43
47
  binary: 'バイナリアセット', large: 'テキスト差分の表示上限を超えています。', bytes: 'バイト',
44
48
  mode: 'Git のファイルモード', permissions: '作業ツリーの実行権限の変更はブラウザーから確認できません。',
45
49
  unchanged: 'テキストに変更はありません。', loading: 'リポジトリを読込中…',
@@ -47,16 +51,20 @@ export const messages = {
47
51
  changed: '読込中にファイルまたは Git の状態が変わりました。再取得してください。',
48
52
  outdated: 'Editor の外でリポジトリが変更されました。再取得すると最新の状態を読み込みます。',
49
53
  unsupported: 'ブラウザーで扱えない種類の Git エントリーがあります。',
50
- pullUnsafe: 'pull の前に実行を止め、未保存編集とローカルの Git 変更を解消してください。',
51
- diverged: 'fast-forward できません。ローカルブランチとファイルは保持されています。',
54
+ pullUnsafe: 'GitHub から取り込む前に、実行を止め、保存していない編集とまだ記録していない変更をなくしてください。',
55
+ diverged: 'このブランチと GitHub の両方に新しい記録があるため、ここでは取り込めません(pull は fast-forward のみ)。ブランチとファイルはそのままです。',
52
56
  session: 'このリポジトリと操作に必要な権限で GitHub に接続してください。',
53
- pushUnknown: 'push の結果が不明です。再試行する前に fetch でリモートブランチを確認してください。',
54
- pushRefresh: 'push は成功しました。fetch でローカルの追跡ブランチを更新してください。',
55
- rollbackFailed: 'Git がローカルの変更をすべて復元できませんでした。作業ツリーと Git の状態を確認してください。',
56
- remoteFailed: 'Git 操作に失敗しました。選択中の接続を確認して再試行してください。',
57
- remoteDone: 'Git 操作が完了しました。',
58
- pulledProject: 'pull が完了しました。「作品を再読込」で更新済みのファイルを開けます。',
59
- confirmPush: '現在のブランチを GitHub の origin に push しますか?',
57
+ pushUnknown: 'GitHub が記録を受け取ったか分かりません。送り直す前に「GitHub と確かめる」を使ってください。',
58
+ pushRefresh: 'GitHub が記録を受け取りました。「GitHub と確かめる」で状態を更新してください。',
59
+ rollbackFailed: '一部のローカルの変更を元に戻せませんでした。ファイルと Git の詳細を確認してください。',
60
+ remoteFailed: 'GitHub との操作を完了できませんでした。接続を確認して、もう一度試してください。',
61
+ remoteDone: 'GitHub との操作が終わりました。',
62
+ pulledProject: 'GitHub から取り込みました。「作品を再読込」で更新したファイルを開けます。',
63
+ confirmPush: 'このブランチの記録を GitHub に送りますか(push)?',
64
+ details: 'Git の詳細', branch: 'ブランチ', head: 'HEAD', groups: 'Git の区分', added: '追加', modified: '変更', deleted: '削除',
65
+ gitStaged: 'ステージ済み (staged)', gitUnstaged: '未ステージ (unstaged)', gitUntracked: '未追跡 (untracked)',
66
+ upstreamSame: 'GitHub と同じ', upstreamAhead: 'GitHub より進んでいる', upstreamBehind: 'GitHub より遅れている', upstreamDiverged: 'GitHub と分かれている',
67
+ upstreamUnknown: 'まだ GitHub と確かめていない', records: '件', checkedAt: '最後に確かめた時点',
60
68
  failed: 'リポジトリを読み込めませんでした。フォルダへのアクセスを確認し、再取得してください。'
61
69
  }
62
70
  };
@@ -7,6 +7,7 @@ import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
7
7
  import {ProjectSourceComparison} from './project-source-review.js';
8
8
  import type {ChangeGroup, FileChange, Repository, Snapshot} from './repository.js';
9
9
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
10
+ import type {ProjectBlockLocator} from './block-diff-location.js';
10
11
 
11
12
  const en = {
12
13
  title: 'Changes by project element', stage: 'Stage', code: (count: number) => `Code · ${count} ${count === 1 ? 'change' : 'changes'}`,
@@ -15,7 +16,7 @@ const en = {
15
16
  files: 'Files and Git groups', before: 'Before', after: 'After', absent: 'Not in this version',
16
17
  loading: 'Reading the project changes…', unreadable: 'The project data could not be read, so the changes are listed by file.',
17
18
  fileList: 'List by file', property: 'Item', format: 'Format', rate: 'Sample rate', samples: 'Samples', file: 'File name',
18
- noImage: 'No image', groups: {staged: 'Staged', unstaged: 'Unstaged', untracked: 'Untracked', commit: 'Commit'} as Record<ChangeGroup, string>
19
+ noImage: 'No image', groups: {staged: 'in the next record', unstaged: 'not yet recorded', untracked: 'new file', commit: 'in this record'} as Record<ChangeGroup, string>
19
20
  };
20
21
  const ja: typeof en = {
21
22
  title: '作品の要素ごとの変更', stage: 'ステージ', code: count => `コード · ${count} か所`,
@@ -24,7 +25,7 @@ const ja: typeof en = {
24
25
  files: 'ファイルと Git の区分', before: '変更前', after: '変更後', absent: 'この版にはありません',
25
26
  loading: '作品の変更を読み込み中…', unreadable: '作品データを読み込めないため、ファイル単位で一覧にします。',
26
27
  fileList: 'ファイル単位の一覧', property: '項目', format: '形式', rate: 'サンプリング周波数', samples: 'サンプル数', file: 'ファイル名',
27
- noImage: '画像なし', groups: {staged: 'ステージ済み', unstaged: '未ステージ', untracked: '未追跡', commit: 'コミット'}
28
+ noImage: '画像なし', groups: {staged: '次の記録に入る', unstaged: 'まだ記録していない', untracked: '新しいファイル', commit: 'この記録'}
28
29
  };
29
30
  type Labels = typeof en;
30
31
  export const projectChangeLabels = (locale: string): Labels => locale.startsWith('ja') ? ja : en;
@@ -164,9 +165,9 @@ const show = (value: unknown) => {
164
165
  };
165
166
 
166
167
  /** The details of one selected row: its code, pictures or values in both versions, and the files behind it. */
167
- export function ProjectElementView({changes, selection, locale, renderBlock, onSelectFile}: {
168
+ export function ProjectElementView({changes, selection, locale, renderBlock, onSelectFile, locateBlock}: {
168
169
  changes: ProjectChanges; selection: {element: ProjectElementChange; target: ProjectTargetChanges | null}; locale: string;
169
- renderBlock?: ProjectBlockRenderer; onSelectFile(file: ChangedFile): void;
170
+ renderBlock?: ProjectBlockRenderer; onSelectFile(file: ChangedFile): void; locateBlock?: ProjectBlockLocator;
170
171
  }) {
171
172
  const labels = projectChangeLabels(locale);
172
173
  const {element, target} = selection;
@@ -189,7 +190,7 @@ export function ProjectElementView({changes, selection, locale, renderBlock, onS
189
190
  <h3>{target && <span>{target.isStage ? labels.stage : target.name} · </span>}{elementTitle(element, target, labels, locale)}</h3>
190
191
  {element.kind === 'code' && target && renderBlock && <ProjectSourceComparison before={changes.sources.before}
191
192
  after={changes.sources.after} renderBlock={renderBlock} locale={locale} loadAsset={changes.loadAsset}
192
- target={{isStage: target.isStage, name: target.name}}/>}
193
+ target={{isStage: target.isStage, name: target.name}} locateBlock={locateBlock}/>}
193
194
  {element.kind === 'costumes' && <div className="twgit-element-pictures">
194
195
  {(['before', 'after'] as const).map(version => <Picture key={version} md5ext={element.md5ext?.[version]} version={version}
195
196
  loadAsset={changes.loadAsset} labels={labels} title={element[version]?.name === undefined ? labels[version] : `${labels[version]} · ${String(element[version]!.name)}`}/>)}
@@ -1,10 +1,12 @@
1
- import React, {useMemo, useState} from 'react';
1
+ import React, {useEffect, useMemo, useRef, useState} from 'react';
2
2
  import {BlockPreview, usePreviewSync} from './block-diff-preview.js';
3
3
  import {isRecord, parseProjectSource, readProjectSourceDiff} from './project-source-patch.js';
4
4
  import type {BlockSide} from './project-source-patch.js';
5
5
  import {compareProjectForReview} from './project-source-review-model.js';
6
6
  import type {ScriptReview, TargetReview} from './project-source-review-model.js';
7
7
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
8
+ import type {ProjectBlockLocator} from './block-diff-location.js';
9
+ import type {LocationStatus} from './block-diff-preview.js';
8
10
  import {currentCostume, useCostumeImage} from './project-assets.js';
9
11
  import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
10
12
 
@@ -15,7 +17,9 @@ export type ProjectSourceReviewFile = {
15
17
  summary?: string;
16
18
  previousPath?: string;
17
19
  unavailable?: boolean;
18
- } & ({patch: string; beforeSource?: string; loadAsset?: ProjectAssetLoader} | {status: 'added' | 'removed' | 'updated'});
20
+ } & ({patch: string; beforeSource?: string; loadAsset?: ProjectAssetLoader;
21
+ /** Offers "Highlight in Editor" on this file's after version, which the caller has matched with the open project. */
22
+ locateBlock?: ProjectBlockLocator} | {status: 'added' | 'removed' | 'updated'});
19
23
 
20
24
  export interface ProjectSourceReviewProps {
21
25
  title: string;
@@ -37,7 +41,9 @@ const ja = {
37
41
  related: '関連ファイル', properties: 'コード以外の変更', invalid: 'この作品の差分を読み込めません。',
38
42
  flag: '緑の旗が押されたとき', broadcast: 'を受け取ったとき', key: 'キーが押されたとき',
39
43
  click: 'このスプライトが押されたとき', script: 'コード', noChanges: '作品に内容の変更はありません。',
40
- comparison: '作品の変更', unreadable: '作品データとして読み込めないため、テキストの差分を表示します。'
44
+ comparison: '作品の変更', unreadable: '作品データとして読み込めないため、テキストの差分を表示します。',
45
+ locate: '編集画面で強調', locating: '編集画面でブロックを検索中…', located: '編集画面で強調しました。',
46
+ missingLocation: '編集画面に該当する版またはブロックがありません。'
41
47
  };
42
48
  const en: typeof ja = {
43
49
  review: 'Change review', fullDescription: 'Show full PR description', checks: 'Review checklist', files: 'Changed files', targets: 'Affected stages and sprites',
@@ -49,7 +55,9 @@ const en: typeof ja = {
49
55
  related: 'Related file', properties: 'Changes outside the code', invalid: 'This project diff could not be read.',
50
56
  flag: 'When green flag clicked', broadcast: 'received', key: 'When key pressed',
51
57
  click: 'When this sprite clicked', script: 'Code', noChanges: 'No changes to the project contents.',
52
- comparison: 'Project changes', unreadable: 'This file could not be read as project data, so its text diff is shown.'
58
+ comparison: 'Project changes', unreadable: 'This file could not be read as project data, so its text diff is shown.',
59
+ locate: 'Highlight in Editor', locating: 'Finding the block in the Editor…', located: 'Highlighted in the Editor.',
60
+ missingLocation: 'This version or block is unavailable in the Editor.'
53
61
  };
54
62
 
55
63
  const properties = new Map<string, readonly [string, string]>([
@@ -74,9 +82,39 @@ function scriptTitle(side: BlockSide, labels: typeof ja) {
74
82
  return typeof block.opcode === 'string' ? block.opcode : labels.script;
75
83
  }
76
84
 
77
- function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset}: {
85
+ type BlockLocation = {locate?(side: BlockSide): void; status(side: BlockSide): LocationStatus | undefined};
86
+ const sideKey = (side: BlockSide) => JSON.stringify([side.target.isStage, side.target.name, side.id]);
87
+
88
+ /** Highlights the after version of a script in the Editor, one request per comparison, and cancels it on unmount. */
89
+ function useBlockLocation(comparison: Comparison, locateBlock?: ProjectBlockLocator): BlockLocation {
90
+ const request = useRef<AbortController>();
91
+ const [location, setLocation] = useState<{comparison: Comparison; key: string; locator: ProjectBlockLocator; status: LocationStatus}>();
92
+ useEffect(() => () => request.current?.abort(), [comparison, locateBlock]);
93
+ const locate = async (side: BlockSide) => {
94
+ if (!locateBlock) return;
95
+ request.current?.abort();
96
+ const controller = new AbortController();
97
+ request.current = controller;
98
+ const selected = {comparison, key: sideKey(side), locator: locateBlock};
99
+ setLocation({...selected, status: 'locating'});
100
+ let found = false;
101
+ try {
102
+ found = await locateBlock({target: side.target, id: side.id, version: 'after', parts: [], signal: controller.signal});
103
+ } catch {
104
+ // The host may replace the active project while resolving its workspace.
105
+ }
106
+ if (!controller.signal.aborted) setLocation({...selected, status: found ? 'located' : 'missingLocation'});
107
+ };
108
+ return {
109
+ locate: locateBlock && (side => void locate(side)),
110
+ status: side => location?.comparison === comparison && location.locator === locateBlock && location.key === sideKey(side) ?
111
+ location.status : undefined
112
+ };
113
+ }
114
+
115
+ function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset, location}: {
78
116
  script: ScriptReview; labels: typeof ja; reviewed?: boolean; setReviewed?(value: boolean): void;
79
- renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
117
+ renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader; location?: BlockLocation;
80
118
  }) {
81
119
  const [open, setOpen] = useState(false);
82
120
  const {sync, toggle} = usePreviewSync(labels === ja ? 'ja' : 'en');
@@ -98,7 +136,9 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
98
136
  {script[version].length ? script[version].map((side, index, sides) => <BlockPreview key={side.id} side={side}
99
137
  title={sides.length > 1 ? `${labels[version]} ${index + 1}` : labels[version]}
100
138
  marks={side.marks} renderBlock={renderBlock} withContext labels={labels}
101
- version={version} loadAsset={loadAsset} sync={sync}/>) :
139
+ version={version} loadAsset={loadAsset} sync={sync}
140
+ onLocate={version === 'after' && location?.locate ? () => location.locate!(side) : undefined}
141
+ locationStatus={version === 'after' ? location?.status(side) : undefined}/>) :
102
142
  <BlockPreview title={labels[version]} renderBlock={renderBlock} withContext labels={labels}/>}
103
143
  </div>)}
104
144
  </div>
@@ -135,10 +175,12 @@ function TargetThumbnails({target, labels, loadAsset}: {target: TargetReview; la
135
175
  }
136
176
 
137
177
  /** Lists the changed stages and sprites of one project file, each with its code comparisons. */
138
- function ComparisonTargets({comparison, labels, renderBlock, loadAsset, review}: {
178
+ function ComparisonTargets({comparison, labels, renderBlock, loadAsset, locateBlock, review}: {
139
179
  comparison: Comparison; labels: typeof ja; renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
180
+ locateBlock?: ProjectBlockLocator;
140
181
  review?: {file: string; keys: ReadonlySet<string>; set(key: string, value: boolean): void};
141
182
  }) {
183
+ const location = useBlockLocation(comparison, locateBlock);
142
184
  return <>
143
185
  {comparison.properties.length > 0 && <p>{labels.properties} · {propertyNames(comparison.properties, labels)}</p>}
144
186
  {!comparison.targets.length && !comparison.properties.length && <p>{labels.noChanges}</p>}
@@ -149,7 +191,7 @@ function ComparisonTargets({comparison, labels, renderBlock, loadAsset, review}:
149
191
  {target.scripts.map(script => {
150
192
  const key = JSON.stringify([review?.file, target.isStage, target.name, script.id]);
151
193
  return <ScriptComparison key={key} script={script} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
152
- reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}/>;
194
+ location={location} reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}/>;
153
195
  })}
154
196
  </section>)}
155
197
  </>;
@@ -168,12 +210,14 @@ export interface ProjectSourceComparisonProps {
168
210
  fallback?: React.ReactNode;
169
211
  /** Reads the images of both versions for thumbnails. Without it, stages and sprites are shown by name. */
170
212
  loadAsset?: ProjectAssetLoader;
213
+ /** Offers "Highlight in Editor" on the after version, which the caller has matched with the open project. */
214
+ locateBlock?: ProjectBlockLocator;
171
215
  /** Shows only the code of this stage or sprite, without the overview and the changes outside the code. */
172
216
  target?: {isStage: boolean; name: string};
173
217
  }
174
218
 
175
219
  /** Compares two versions of a project.source.json by stage, sprite and code, as the pull request review does. */
176
- export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset, target}: ProjectSourceComparisonProps) {
220
+ export function ProjectSourceComparison({before, after, renderBlock, locale = 'en', fallback, loadAsset, locateBlock, target}: ProjectSourceComparisonProps) {
177
221
  const labels = locale.startsWith('ja') ? ja : en;
178
222
  const only = target && JSON.stringify([target.isStage, target.isStage || target.name]);
179
223
  const comparison = useMemo(() => {
@@ -196,7 +240,8 @@ export function ProjectSourceComparison({before, after, renderBlock, locale = 'e
196
240
  <div><dt>{labels.scripts}</dt><dd>{countScripts(comparison)}</dd></div>
197
241
  </dl>
198
242
  </header>}
199
- <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}/>
243
+ <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
244
+ locateBlock={locateBlock}/>
200
245
  </section>;
201
246
  }
202
247
 
@@ -251,6 +296,7 @@ export function ProjectSourceReview({title, description, verification, files, re
251
296
  {(invalid || file.unavailable) && <p role="alert">{labels.invalid}</p>}
252
297
  {comparison && <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}
253
298
  loadAsset={'loadAsset' in file ? file.loadAsset : undefined}
299
+ locateBlock={'locateBlock' in file ? file.locateBlock : undefined}
254
300
  review={{file: file.path, keys: reviewed, set: setReviewed}}/>}
255
301
  </section>)}
256
302
  </section>;