@kubohiroya/turbowarp-plus-editor-git 0.10.0 → 0.12.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/dist/style.css CHANGED
@@ -106,6 +106,20 @@
106
106
  border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas));
107
107
  border-radius: 4px;
108
108
  }
109
+
110
+ /* The legend matches the outlines drawn on the previews. */
111
+ .twgit-review-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0; font-size: .9em; }
112
+ .twgit-review-legend span::before { content: ''; display: inline-block; width: 12px; height: 10px; margin-inline-end: 5px;
113
+ vertical-align: -1px; border: 2.5px solid var(--twgit-change); border-radius: 3px; }
114
+ .twgit-review-legend [data-legend="added"] { --twgit-change: #25875d; }
115
+ .twgit-review-legend [data-legend="removed"] { --twgit-change: #c54a53; }
116
+ .twgit-review-legend [data-legend="updated"] { --twgit-change: #635bce; }
117
+ .twgit-review-legend [data-legend="part"] { --twgit-change: #b4510a; }
118
+ .twgit-review-legend [data-legend="part"]::before { border-radius: 0; }
119
+ .twgit-review-summary { color: color-mix(in srgb, CanvasText 72%, Canvas); }
120
+ .twgit-review-target-status { font-weight: 600; }
121
+ .twgit-review-asset { display: flex; align-items: center; gap: 8px; }
122
+ .twgit-review-asset img { width: 32px; height: 32px; object-fit: contain; }
109
123
  :root{--diff-background-color:initial;--diff-text-color:initial;--diff-font-family:Consolas,Courier,monospace;--diff-selection-background-color:#b3d7ff;--diff-selection-text-color:var(--diff-text-color);--diff-gutter-insert-background-color:#d6fedb;--diff-gutter-insert-text-color:var(--diff-text-color);--diff-gutter-delete-background-color:#fadde0;--diff-gutter-delete-text-color:var(--diff-text-color);--diff-gutter-selected-background-color:#fffce0;--diff-gutter-selected-text-color:var(--diff-text-color);--diff-code-insert-background-color:#eaffee;--diff-code-insert-text-color:var(--diff-text-color);--diff-code-delete-background-color:#fdeff0;--diff-code-delete-text-color:var(--diff-text-color);--diff-code-insert-edit-background-color:#c0dc91;--diff-code-insert-edit-text-color:var(--diff-text-color);--diff-code-delete-edit-background-color:#f39ea2;--diff-code-delete-edit-text-color:var(--diff-text-color);--diff-code-selected-background-color:#fffce0;--diff-code-selected-text-color:var(--diff-text-color);--diff-omit-gutter-line-color:#cb2a1d;}.diff{background-color:var(--diff-background-color);border-collapse:collapse;color:var(--diff-text-color);table-layout:fixed;width:100%;}.diff::-moz-selection{background-color:#b3d7ff;background-color:var(--diff-selection-background-color);color:var(--diff-text-color);color:var(--diff-selection-text-color);}.diff::selection{background-color:#b3d7ff;background-color:var(--diff-selection-background-color);color:var(--diff-text-color);color:var(--diff-selection-text-color);}.diff td{padding-bottom:0;padding-top:0;vertical-align:top;}.diff-line{font-family:Consolas,Courier,monospace;font-family:var(--diff-font-family);line-height:1.5;}.diff-gutter>a{color:inherit;display:block;}.diff-gutter{cursor:pointer;padding:0 1ch;text-align:right;-webkit-user-select:none;-moz-user-select:none;user-select:none;}.diff-gutter-insert{background-color:#d6fedb;background-color:var(--diff-gutter-insert-background-color);color:var(--diff-text-color);color:var(--diff-gutter-insert-text-color);}.diff-gutter-delete{background-color:#fadde0;background-color:var(--diff-gutter-delete-background-color);color:var(--diff-text-color);color:var(--diff-gutter-delete-text-color);}.diff-gutter-omit{cursor:default;}.diff-gutter-selected{background-color:#fffce0;background-color:var(--diff-gutter-selected-background-color);color:var(--diff-text-color);color:var(--diff-gutter-selected-text-color);}.diff-code{word-wrap:break-word;padding:0 0 0 .5em;white-space:pre-wrap;word-break:break-all;}.diff-code-edit{color:inherit;}.diff-code-insert{background-color:#eaffee;background-color:var(--diff-code-insert-background-color);color:var(--diff-text-color);color:var(--diff-code-insert-text-color);}.diff-code-insert .diff-code-edit{background-color:#c0dc91;background-color:var(--diff-code-insert-edit-background-color);color:var(--diff-text-color);color:var(--diff-code-insert-edit-text-color);}.diff-code-delete{background-color:#fdeff0;background-color:var(--diff-code-delete-background-color);color:var(--diff-text-color);color:var(--diff-code-delete-text-color);}.diff-code-delete .diff-code-edit{background-color:#f39ea2;background-color:var(--diff-code-delete-edit-background-color);color:var(--diff-text-color);color:var(--diff-code-delete-edit-text-color);}.diff-code-selected{background-color:#fffce0;background-color:var(--diff-code-selected-background-color);color:var(--diff-text-color);color:var(--diff-code-selected-text-color);}.diff-widget-content{vertical-align:top;}.diff-gutter-col{width:7ch;}.diff-gutter-omit{height:0;}.diff-gutter-omit:before{background-color:#cb2a1d;background-color:var(--diff-omit-gutter-line-color);content:" ";display:block;height:100%;margin-left:4.6ch;overflow:hidden;white-space:pre;width:2px;}.diff-decoration{line-height:1.5;-webkit-user-select:none;-moz-user-select:none;user-select:none;}.diff-decoration-content{font-family:Consolas,Courier,monospace;font-family:var(--diff-font-family);padding:0;}.twgit-backdrop { position: fixed; inset: 0; z-index: 65000; background: #0006; display: flex; align-items: center; justify-content: center; }
110
124
  .twgit-panel { width: 94vw; height: 90vh; display: flex; flex-direction: column; background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 8px; box-shadow: 0 12px 48px #0005; font: 14px/1.5 system-ui, sans-serif; overflow: hidden; }
111
125
  .twgit-panel > header { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid GrayText; }
@@ -163,4 +177,6 @@
163
177
  .twgit-element-files { margin-top: 12px; }
164
178
  .twgit-panel .twgit-element span.twgit-element-name { width: auto; font-family: inherit; }
165
179
  .twgit-git-details > .twgit-connection-body { left: auto; right: 0; }
180
+ /* In a layout tab the panel fills the tab and leaves the Editor beside it. */
181
+ .twgit-panel.twgit-panel-tab { width: 100%; height: 100%; border: 0; border-radius: 0; box-shadow: none; }
166
182
  /*$vite$:1*/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-git",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -0,0 +1,55 @@
1
+ import {createScratchBlocksDiffLocator} from './block-diff-location.js';
2
+ import type {ProjectBlockLocator, ScratchBlockLocationWorkspace} from './block-diff-location.js';
3
+
4
+ /** The parts of a Scratch VM the locator reads to find a stage or sprite. */
5
+ export interface LocatorVM {
6
+ runtime: {targets: readonly {id: string; isOriginal?: boolean; isStage: boolean; getName(): string}[]};
7
+ editingTarget?: {id: string} | null;
8
+ }
9
+
10
+ export interface EditorBlockLocatorOptions {
11
+ vm: LocatorVM;
12
+ /** Opens the code of one stage or sprite in the Editor, as project search does. */
13
+ openTargetCode(targetId: string): void | Promise<void>;
14
+ /** The Editor's code workspace as it is now, or `null` while none is shown. */
15
+ getWorkspace(): ScratchBlockLocationWorkspace | null | Promise<ScratchBlockLocationWorkspace | null>;
16
+ }
17
+
18
+ /**
19
+ * Finds the stage or sprite of a compared version in the open project, opens its code and waits for the block,
20
+ * then centers and outlines it. The caller decides which version matches the open project.
21
+ */
22
+ export function createEditorBlockLocator({vm, openTargetCode, getWorkspace}: EditorBlockLocatorOptions): ProjectBlockLocator {
23
+ return createScratchBlocksDiffLocator(async ({target, id: blockId, signal}) => {
24
+ const found = vm.runtime.targets.find(item => target.isStage ? item.isStage :
25
+ !item.isStage && item.isOriginal !== false && item.getName() === target.name);
26
+ if (!found || signal.aborted) return null;
27
+ await openTargetCode(found.id);
28
+ // The Editor switches targets and fills its workspace over the next frames.
29
+ const deadline = Date.now() + waitLimit;
30
+ while (!signal.aborted) {
31
+ if (vm.editingTarget?.id === found.id) {
32
+ const workspace = await getWorkspace();
33
+ if (signal.aborted) break;
34
+ if (workspace?.getBlockById(blockId)) return workspace;
35
+ }
36
+ if (Date.now() >= deadline) break;
37
+ await nextFrame(signal);
38
+ }
39
+ return null;
40
+ });
41
+ }
42
+
43
+ const waitLimit = 2000;
44
+
45
+ function nextFrame(signal: AbortSignal) {
46
+ return new Promise<void>(resolve => {
47
+ const done = () => {
48
+ signal.removeEventListener('abort', done);
49
+ resolve();
50
+ };
51
+ signal.addEventListener('abort', done, {once: true});
52
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(done);
53
+ else setTimeout(done, 16);
54
+ });
55
+ }
@@ -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,9 +16,9 @@ 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;
@@ -40,7 +41,7 @@ export function FileDiffView({change, diff, labels, locale, renderBlock, loadAss
40
41
  {(['blocks', 'text'] as const).map(mode => <button key={mode} type="button" aria-pressed={view === mode}
41
42
  data-git-view={mode} onClick={() => setView(mode)}>{names[mode]}</button>)}
42
43
  </div>}
43
- {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}
44
45
  before={change.before === null ? null : sources.before} after={change.after === null ? null : sources.after}/> : text}
45
46
  </>;
46
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,7 +331,7 @@ 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>
@@ -312,7 +361,8 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
312
361
  {state.busy && <p role="status">{labels.loading}</p>}
313
362
  {!state.repository && <p>{labels.empty}</p>}
314
363
  {review && state.repository && host.renderReviewBlock &&
315
- <PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}/>}
364
+ <PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}
365
+ locateFor={reviewLocator}/>}
316
366
  {!review && state.repository && <div className="twgit-content">
317
367
  <aside>
318
368
  {state.browser && <details>
@@ -350,12 +400,20 @@ function GitPanel({document, host, controller}: {document: Document; host: GitHo
350
400
  </aside>
351
401
  <main className="twgit-diff">
352
402
  {selection && projectList ? <ProjectElementView changes={projectList} selection={selection} locale={locale}
403
+ locateBlock={state.commit ? undefined : locator}
353
404
  renderBlock={host.renderReviewBlock} onSelectFile={openFile}/> :
354
405
  state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
406
+ locateBlock={fileLocator(state.selected)}
355
407
  renderBlock={host.renderReviewBlock} loadAsset={loadAsset}/> : <p>{labels.select}</p>}
356
408
  </main>
357
409
  </div>}
358
- </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>
359
417
  </div>;
360
418
  }
361
419
 
@@ -376,7 +434,7 @@ export function mountGitPanel({vm, document, host}: {vm: object; document: Docum
376
434
  document.body.append(root);
377
435
  const controller: Controller = {open() {}, async refresh() {}};
378
436
  controllers.set(vm, controller);
379
- render(<GitPanel document={document} host={host} controller={controller}/>, root);
437
+ render(<GitPanel vm={vm} document={document} host={host} controller={controller}/>, root);
380
438
  return {refresh: () => controller.refresh(), dispose() {
381
439
  if (controllers.get(vm) === controller) controllers.delete(vm);
382
440
  void host.connectionSettings?.disconnect().catch(() => {});
@@ -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'}`,
@@ -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)}`}/>)}
@@ -106,3 +106,17 @@
106
106
  border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas));
107
107
  border-radius: 4px;
108
108
  }
109
+
110
+ /* The legend matches the outlines drawn on the previews. */
111
+ .twgit-review-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0; font-size: .9em; }
112
+ .twgit-review-legend span::before { content: ''; display: inline-block; width: 12px; height: 10px; margin-inline-end: 5px;
113
+ vertical-align: -1px; border: 2.5px solid var(--twgit-change); border-radius: 3px; }
114
+ .twgit-review-legend [data-legend="added"] { --twgit-change: #25875d; }
115
+ .twgit-review-legend [data-legend="removed"] { --twgit-change: #c54a53; }
116
+ .twgit-review-legend [data-legend="updated"] { --twgit-change: #635bce; }
117
+ .twgit-review-legend [data-legend="part"] { --twgit-change: #b4510a; }
118
+ .twgit-review-legend [data-legend="part"]::before { border-radius: 0; }
119
+ .twgit-review-summary { color: color-mix(in srgb, CanvasText 72%, Canvas); }
120
+ .twgit-review-target-status { font-weight: 600; }
121
+ .twgit-review-asset { display: flex; align-items: center; gap: 8px; }
122
+ .twgit-review-asset img { width: 32px; height: 32px; object-fit: contain; }
@@ -15,6 +15,8 @@ export {createScratchBlocksDiffLocator} from './block-diff-location.js';
15
15
  export type {ProjectBlockLocation, ProjectBlockLocator, ScratchBlockLocationWorkspace} from './block-diff-location.js';
16
16
  export type {BlockPart} from './project-source-patch.js';
17
17
  export {createProjectSourceDiff} from './project-source-patch.js';
18
+ export {compareProjectForReview} from './project-source-review-model.js';
19
+ export type {ScriptChangeKind, ScriptReview, ScriptReviewSide, ScriptSummaryItem, TargetReview} from './project-source-review-model.js';
18
20
  export {currentCostume} from './project-assets.js';
19
21
  export {assetFileName, organizeProjectChanges, projectPropertyLabel} from './project-change-model.js';
20
22
  export type {ChangedFile, OrganizeProjectChangesInput, ProjectChangeList, ProjectChangeStatus, ProjectElementChange, ProjectElementKind,
@@ -3,14 +3,33 @@ import type {BlockChange, BlockSide, ProjectSource, ProjectTarget} from './proje
3
3
  import type {ScriptBlockMark} from './scratch-block-diff-renderer.js';
4
4
 
5
5
  export interface ScriptReviewSide extends BlockSide { marks: ScriptBlockMark[] }
6
+ /**
7
+ * What happened to one script. `moved` means only positions changed, `reordered` only how blocks connect;
8
+ * neither changes what the script does.
9
+ */
10
+ export type ScriptChangeKind = 'added' | 'removed' | 'updated' | 'reorganized' | 'moved' | 'reordered';
11
+ /** One phrase of a script's summary, in the order shown. The view words it in the reader's language. */
12
+ export type ScriptSummaryItem =
13
+ | {kind: 'hatReplaced'; before: string; after: string} // hat block opcodes
14
+ | {kind: 'wrapped'; opcode: string} // a new C block now holds blocks that existed before
15
+ | {kind: 'branchAdded'; opcode: string} // control_if or control_if_else added
16
+ | {kind: 'valueChanged'; opcode: string; name: string} // an input or field of an existing block, by its name
17
+ | {kind: 'blocksAdded'; count: number}
18
+ | {kind: 'blocksRemoved'; count: number}
19
+ | {kind: 'reordered'}
20
+ | {kind: 'moved'};
6
21
  export interface ScriptReview {
7
22
  id: string;
8
23
  before: ScriptReviewSide[];
9
24
  after: ScriptReviewSide[];
25
+ kind: ScriptChangeKind;
26
+ summary: ScriptSummaryItem[];
10
27
  }
11
28
  export interface TargetReview {
12
29
  name: string;
13
30
  isStage: boolean;
31
+ /** Added or removed as a whole, or changed. Added and removed targets list no properties. */
32
+ status: 'added' | 'removed' | 'updated';
14
33
  /** The stage or sprite in each version, absent where it does not exist. */
15
34
  before?: ProjectTarget;
16
35
  after?: ProjectTarget;
@@ -66,24 +85,149 @@ function groupScripts(before: ProjectTarget | undefined, after: ProjectTarget |
66
85
  for (const root of new Set(roots[version].values())) {
67
86
  const key = version + ':' + root;
68
87
  const id = groupOf(key);
69
- const group = groups.get(id) ?? {id, before: [], after: []};
88
+ const group = groups.get(id) ?? {id, before: [], after: [], kind: 'updated' as ScriptChangeKind, summary: [] as ScriptSummaryItem[]};
70
89
  const side: ScriptReviewSide = {target, id: root, marks: []};
71
90
  group[version].push(side);
72
91
  groups.set(id, group);
73
92
  sides.set(key, side);
74
93
  }
75
94
  }
76
- const affected = new Set<string>();
95
+ const affected = new Map<string, BlockChange[]>();
77
96
  for (const change of changes) {
78
97
  for (const version of ['before', 'after'] as const) {
79
98
  const block = change[version];
80
99
  if (!block) continue;
81
100
  const key = version + ':' + roots[version].get(block.id);
82
- sides.get(key)?.marks.push({id: block.id, kind: change.kind, parts: change.parts});
83
- affected.add(groupOf(key));
101
+ // Only content changes are marked; a block that was merely reconnected or moved looks the same.
102
+ if (!isLayoutOnly(change)) sides.get(key)?.marks.push({id: block.id, kind: change.kind, parts: change.parts});
103
+ const id = groupOf(key);
104
+ const list = affected.get(id) ?? [];
105
+ if (!list.includes(change)) list.push(change);
106
+ affected.set(id, list);
107
+ }
108
+ }
109
+ return [...affected].map(([id, list]) => {
110
+ const group = groups.get(id)!;
111
+ group.kind = scriptKind(group, list);
112
+ group.summary = summarize(group, list, before, after);
113
+ return group;
114
+ });
115
+ }
116
+
117
+ const connectionFields = new Set(['next', 'parent', 'topLevel']);
118
+ const positionFields = new Set(['x', 'y']);
119
+ // Compact top-level reporters are arrays: [type, name, id, x, y].
120
+ const compactPositionOnly = (a: unknown, b: unknown) =>
121
+ Array.isArray(a) && Array.isArray(b) && equal(a.slice(0, 3), b.slice(0, 3)) && !equal(a, b);
122
+
123
+ function blockOf(side: BlockSide | undefined) {
124
+ return side ? side.target.blocks[side.id] : undefined;
125
+ }
126
+
127
+ /** True for an updated block whose content is the same: only its connections or its position changed. */
128
+ function isLayoutOnly(change: BlockChange) {
129
+ if (change.kind !== 'updated') return false;
130
+ if (compactPositionOnly(blockOf(change.before), blockOf(change.after))) return true;
131
+ return change.fields.length > 0 && change.fields.every(field => connectionFields.has(field) || positionFields.has(field));
132
+ }
133
+
134
+ function isPositionOnly(change: BlockChange) {
135
+ if (change.kind !== 'updated') return false;
136
+ if (compactPositionOnly(blockOf(change.before), blockOf(change.after))) return true;
137
+ return change.fields.length > 0 && change.fields.every(field => positionFields.has(field));
138
+ }
139
+
140
+ function scriptKind(group: ScriptReview, changes: readonly BlockChange[]): ScriptChangeKind {
141
+ if (!group.before.length) return 'added';
142
+ if (!group.after.length) return 'removed';
143
+ if (group.before.length !== 1 || group.after.length !== 1) return 'reorganized';
144
+ if (changes.every(isPositionOnly)) return 'moved';
145
+ if (changes.every(isLayoutOnly)) return 'reordered';
146
+ return 'updated';
147
+ }
148
+
149
+ const opcodeOf = (block: unknown) => isRecord(block) && typeof block.opcode === 'string' ? block.opcode : undefined;
150
+ const isShadow = (block: unknown) => !isRecord(block) || block.shadow === true;
151
+ const isHat = (opcode: string | undefined) => opcode !== undefined &&
152
+ (opcode === 'control_start_as_clone' || opcode === 'procedures_definition' || /^[a-z0-9]+_when/i.test(opcode));
153
+ const substackInputs = ['SUBSTACK', 'SUBSTACK2'];
154
+
155
+ function inputBlock(block: unknown, name: string) {
156
+ if (!isRecord(block) || !isRecord(block.inputs)) return undefined;
157
+ const input = block.inputs[name];
158
+ return Array.isArray(input) && typeof input[1] === 'string' ? input[1] : undefined;
159
+ }
160
+
161
+ function summarize(group: ScriptReview, changes: readonly BlockChange[], before?: ProjectTarget, after?: ProjectTarget) {
162
+ const items: ScriptSummaryItem[] = [];
163
+ const seen = new Set<string>();
164
+ const add = (item: ScriptSummaryItem) => {
165
+ const key = JSON.stringify(item);
166
+ if (seen.has(key)) return;
167
+ seen.add(key);
168
+ items.push(item);
169
+ };
170
+ if (group.kind === 'moved' || group.kind === 'reordered') {
171
+ add({kind: group.kind});
172
+ return items;
173
+ }
174
+ const explained = new Set<BlockChange>();
175
+ if (group.before.length === 1 && group.after.length === 1) {
176
+ const oldRoot = group.before[0].id, newRoot = group.after[0].id;
177
+ const a = opcodeOf(before?.blocks[oldRoot]), b = opcodeOf(after?.blocks[newRoot]);
178
+ if (a !== b && isHat(a) && isHat(b)) {
179
+ add({kind: 'hatReplaced', before: a!, after: b!});
180
+ // The new hat and the one it replaced are the replacement, not separate additions and removals.
181
+ for (const change of changes) {
182
+ if ((change.kind === 'added' && change.after?.id === newRoot) || (change.kind === 'removed' && change.before?.id === oldRoot)) {
183
+ explained.add(change);
184
+ }
185
+ }
186
+ }
187
+ }
188
+ const branches: BlockChange[] = [];
189
+ for (const change of changes) {
190
+ if (change.kind !== 'added' || !after) continue;
191
+ const block = after.blocks[change.after!.id];
192
+ const opcode = opcodeOf(block);
193
+ if (!opcode) continue;
194
+ const wraps = substackInputs.some(name => {
195
+ const visited = new Set<string>();
196
+ for (let id = inputBlock(block, name); id !== undefined && !visited.has(id);) {
197
+ visited.add(id);
198
+ if (before && Object.hasOwn(before.blocks, id)) return true;
199
+ const next = after.blocks[id];
200
+ id = isRecord(next) && typeof next.next === 'string' ? next.next : undefined;
201
+ }
202
+ return false;
203
+ });
204
+ if (wraps) {
205
+ add({kind: 'wrapped', opcode});
206
+ explained.add(change);
207
+ } else if (opcode === 'control_if' || opcode === 'control_if_else') {
208
+ branches.push(change);
209
+ }
210
+ }
211
+ for (const change of branches) {
212
+ add({kind: 'branchAdded', opcode: opcodeOf(after!.blocks[change.after!.id])!});
213
+ explained.add(change);
214
+ }
215
+ for (const change of changes) {
216
+ if (change.kind !== 'updated') continue;
217
+ const opcode = opcodeOf(blockOf(change.after)) ?? opcodeOf(blockOf(change.before));
218
+ if (!opcode) continue;
219
+ for (const part of change.parts) {
220
+ // A C block's slot holds blocks; what changed there is reported as additions, removals or wrapping.
221
+ if (part.kind === 'input' && substackInputs.includes(part.name)) continue;
222
+ add({kind: 'valueChanged', opcode, name: part.name});
84
223
  }
85
224
  }
86
- return [...affected].map(id => groups.get(id)!);
225
+ const count = (kind: 'added' | 'removed') => changes.filter(change => change.kind === kind && !explained.has(change) &&
226
+ !isShadow(blockOf(kind === 'added' ? change.after : change.before))).length;
227
+ const added = count('added'), removed = count('removed');
228
+ if (added) add({kind: 'blocksAdded', count: added});
229
+ if (removed) add({kind: 'blocksRemoved', count: removed});
230
+ return items;
87
231
  }
88
232
 
89
233
  function changedProperties(before: Record<string, unknown>, after: Record<string, unknown>, except: string) {
@@ -106,10 +250,12 @@ export function compareProjectForReview(before: ProjectSource, after: ProjectSou
106
250
  const a = oldTargets.get(key), b = newTargets.get(key);
107
251
  const edits = byTarget.get(key);
108
252
  const scripts = edits ? groupScripts(a, b, edits) : [];
109
- const properties = changedProperties({...a}, {...b}, 'blocks');
110
- if (scripts.length || properties.length) {
253
+ const status = !a ? 'added' : !b ? 'removed' : 'updated';
254
+ // An added or removed target is shown as a whole; listing every key it has would say nothing more.
255
+ const properties = status === 'updated' ? changedProperties({...a}, {...b}, 'blocks') : [];
256
+ if (scripts.length || properties.length || status !== 'updated') {
111
257
  const {name, isStage} = (b ?? a)!;
112
- targets.push({name, isStage, scripts, properties, before: a, after: b});
258
+ targets.push({name, isStage, status, scripts, properties, before: a, after: b});
113
259
  }
114
260
  }
115
261
  return {targets, properties: changedProperties(before, after, 'targets')};