@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/editor-block-locator.d.ts +27 -0
- package/dist/file-diff-view.d.ts +3 -1
- package/dist/index.d.ts +25 -0
- package/dist/index.js +126 -32
- package/dist/project-change-list.d.ts +3 -1
- package/dist/project-source-diff.d.ts +2 -0
- package/dist/project-source-diff.js +2 -2
- package/dist/project-source-review-model.d.ts +35 -0
- package/dist/project-source-review.d.ts +6 -1
- package/dist/pull-request-panel.d.ts +4 -1
- package/dist/review-words.d.ts +6 -0
- package/dist/{scratch-block-diff-renderer-DZR8ep-T.js → scratch-block-diff-renderer-e_drVlHE.js} +598 -263
- package/dist/style.css +16 -0
- package/package.json +1 -1
- package/src/editor-block-locator.ts +55 -0
- package/src/file-diff-view.tsx +4 -3
- package/src/index.tsx +74 -16
- package/src/project-change-list.tsx +4 -3
- package/src/project-source-diff.css +14 -0
- package/src/project-source-diff.tsx +2 -0
- package/src/project-source-review-model.ts +154 -8
- package/src/project-source-review.tsx +136 -24
- package/src/pull-request-panel.tsx +9 -4
- package/src/review-words.ts +66 -0
- package/src/style.css +2 -0
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
|
@@ -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
|
+
}
|
package/src/file-diff-view.tsx
CHANGED
|
@@ -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 => {
|
|
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 (!
|
|
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
|
-
}, [
|
|
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();
|
|
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 (!
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
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')};
|