@kgdiff/viewer 0.4.0 → 0.6.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.
Files changed (257) hide show
  1. package/README.md +43 -16
  2. package/custom-elements.json +2347 -0
  3. package/dist/bind.d.ts +41 -0
  4. package/dist/bind.d.ts.map +1 -0
  5. package/dist/bind.js +47 -0
  6. package/dist/bind.js.map +1 -0
  7. package/dist/context.d.ts +42 -0
  8. package/dist/context.d.ts.map +1 -0
  9. package/dist/context.js +87 -0
  10. package/dist/context.js.map +1 -0
  11. package/dist/controller.d.ts +26 -237
  12. package/dist/controller.d.ts.map +1 -1
  13. package/dist/controller.js +33 -1484
  14. package/dist/controller.js.map +1 -1
  15. package/dist/dom.d.ts +10 -0
  16. package/dist/dom.d.ts.map +1 -1
  17. package/dist/dom.js +29 -0
  18. package/dist/dom.js.map +1 -1
  19. package/dist/element.d.ts +51 -42
  20. package/dist/element.d.ts.map +1 -1
  21. package/dist/element.js +381 -602
  22. package/dist/element.js.map +1 -1
  23. package/dist/elements/actions.d.ts +27 -0
  24. package/dist/elements/actions.d.ts.map +1 -0
  25. package/dist/elements/actions.js +81 -0
  26. package/dist/elements/actions.js.map +1 -0
  27. package/dist/elements/bound.d.ts +63 -0
  28. package/dist/elements/bound.d.ts.map +1 -0
  29. package/dist/elements/bound.js +213 -0
  30. package/dist/elements/bound.js.map +1 -0
  31. package/dist/elements/change-detail.d.ts +25 -0
  32. package/dist/elements/change-detail.d.ts.map +1 -0
  33. package/dist/elements/change-detail.js +19 -0
  34. package/dist/elements/change-detail.js.map +1 -0
  35. package/dist/elements/change-list.d.ts +27 -0
  36. package/dist/elements/change-list.d.ts.map +1 -0
  37. package/dist/elements/change-list.js +29 -0
  38. package/dist/elements/change-list.js.map +1 -0
  39. package/dist/elements/filters.d.ts +25 -0
  40. package/dist/elements/filters.d.ts.map +1 -0
  41. package/dist/elements/filters.js +19 -0
  42. package/dist/elements/filters.js.map +1 -0
  43. package/dist/elements/legend.d.ts +40 -0
  44. package/dist/elements/legend.d.ts.map +1 -0
  45. package/dist/elements/legend.js +87 -0
  46. package/dist/elements/legend.js.map +1 -0
  47. package/dist/elements/minimap.d.ts +38 -0
  48. package/dist/elements/minimap.d.ts.map +1 -0
  49. package/dist/elements/minimap.js +59 -0
  50. package/dist/elements/minimap.js.map +1 -0
  51. package/dist/elements/root.d.ts +40 -0
  52. package/dist/elements/root.d.ts.map +1 -0
  53. package/dist/elements/root.js +67 -0
  54. package/dist/elements/root.js.map +1 -0
  55. package/dist/elements/search.d.ts +29 -0
  56. package/dist/elements/search.d.ts.map +1 -0
  57. package/dist/elements/search.js +23 -0
  58. package/dist/elements/search.js.map +1 -0
  59. package/dist/elements/stage-context.d.ts +31 -0
  60. package/dist/elements/stage-context.d.ts.map +1 -0
  61. package/dist/elements/stage-context.js +4 -0
  62. package/dist/elements/stage-context.js.map +1 -0
  63. package/dist/elements/stage.d.ts +65 -0
  64. package/dist/elements/stage.d.ts.map +1 -0
  65. package/dist/elements/stage.js +259 -0
  66. package/dist/elements/stage.js.map +1 -0
  67. package/dist/elements/title.d.ts +25 -0
  68. package/dist/elements/title.d.ts.map +1 -0
  69. package/dist/elements/title.js +19 -0
  70. package/dist/elements/title.js.map +1 -0
  71. package/dist/elements/transport.d.ts +29 -0
  72. package/dist/elements/transport.d.ts.map +1 -0
  73. package/dist/elements/transport.js +24 -0
  74. package/dist/elements/transport.js.map +1 -0
  75. package/dist/elements/view-bound.d.ts +39 -0
  76. package/dist/elements/view-bound.d.ts.map +1 -0
  77. package/dist/elements/view-bound.js +88 -0
  78. package/dist/elements/view-bound.js.map +1 -0
  79. package/dist/elements/view-dock.d.ts +28 -0
  80. package/dist/elements/view-dock.d.ts.map +1 -0
  81. package/dist/elements/view-dock.js +28 -0
  82. package/dist/elements/view-dock.js.map +1 -0
  83. package/dist/elements/zoom.d.ts +28 -0
  84. package/dist/elements/zoom.d.ts.map +1 -0
  85. package/dist/elements/zoom.js +22 -0
  86. package/dist/elements/zoom.js.map +1 -0
  87. package/dist/i18n.d.ts +10 -0
  88. package/dist/i18n.d.ts.map +1 -1
  89. package/dist/i18n.js +20 -0
  90. package/dist/i18n.js.map +1 -1
  91. package/dist/icons.d.ts +1 -0
  92. package/dist/icons.d.ts.map +1 -1
  93. package/dist/icons.js +1 -0
  94. package/dist/icons.js.map +1 -1
  95. package/dist/index.d.ts +331 -36
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +13 -0
  98. package/dist/index.js.map +1 -1
  99. package/dist/keyboard.d.ts +39 -2
  100. package/dist/keyboard.d.ts.map +1 -1
  101. package/dist/keyboard.js +145 -62
  102. package/dist/keyboard.js.map +1 -1
  103. package/dist/layout-store.d.ts +26 -0
  104. package/dist/layout-store.d.ts.map +1 -0
  105. package/dist/layout-store.js +46 -0
  106. package/dist/layout-store.js.map +1 -0
  107. package/dist/panel-levels.d.ts +18 -0
  108. package/dist/panel-levels.d.ts.map +1 -0
  109. package/dist/panel-levels.js +41 -0
  110. package/dist/panel-levels.js.map +1 -0
  111. package/dist/panels.d.ts +28 -0
  112. package/dist/panels.d.ts.map +1 -0
  113. package/dist/panels.js +73 -0
  114. package/dist/panels.js.map +1 -0
  115. package/dist/pipeline.d.ts.map +1 -1
  116. package/dist/pipeline.js +3 -2
  117. package/dist/pipeline.js.map +1 -1
  118. package/dist/prefs.d.ts +5 -2
  119. package/dist/prefs.d.ts.map +1 -1
  120. package/dist/prefs.js +12 -3
  121. package/dist/prefs.js.map +1 -1
  122. package/dist/react.d.ts +460 -0
  123. package/dist/react.d.ts.map +1 -0
  124. package/dist/react.js +2 -0
  125. package/dist/react.js.map +1 -0
  126. package/dist/root-element.d.ts +122 -0
  127. package/dist/root-element.d.ts.map +1 -0
  128. package/dist/root-element.js +541 -0
  129. package/dist/root-element.js.map +1 -0
  130. package/dist/root.d.ts +54 -0
  131. package/dist/root.d.ts.map +1 -0
  132. package/dist/root.js +288 -0
  133. package/dist/root.js.map +1 -0
  134. package/dist/schedule.d.ts +22 -0
  135. package/dist/schedule.d.ts.map +1 -0
  136. package/dist/schedule.js +41 -0
  137. package/dist/schedule.js.map +1 -0
  138. package/dist/session.d.ts +269 -0
  139. package/dist/session.d.ts.map +1 -0
  140. package/dist/session.js +1237 -0
  141. package/dist/session.js.map +1 -0
  142. package/dist/standalone/kgdiff-viewer-compute.js +1 -1
  143. package/dist/standalone/kgdiff-viewer.js +4 -4
  144. package/dist/styles.d.ts +17 -6
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +367 -258
  147. package/dist/styles.js.map +1 -1
  148. package/dist/theme.d.ts +32 -0
  149. package/dist/theme.d.ts.map +1 -0
  150. package/dist/theme.js +152 -0
  151. package/dist/theme.js.map +1 -0
  152. package/dist/topics.d.ts +8 -0
  153. package/dist/topics.d.ts.map +1 -0
  154. package/dist/topics.js +18 -0
  155. package/dist/topics.js.map +1 -0
  156. package/dist/ui/change-list.d.ts +18 -0
  157. package/dist/ui/change-list.d.ts.map +1 -0
  158. package/dist/ui/change-list.js +331 -0
  159. package/dist/ui/change-list.js.map +1 -0
  160. package/dist/ui/context.d.ts +34 -7
  161. package/dist/ui/context.d.ts.map +1 -1
  162. package/dist/ui/context.js.map +1 -1
  163. package/dist/ui/filters.d.ts +9 -0
  164. package/dist/ui/filters.d.ts.map +1 -0
  165. package/dist/ui/filters.js +68 -0
  166. package/dist/ui/filters.js.map +1 -0
  167. package/dist/ui/header.d.ts +41 -3
  168. package/dist/ui/header.d.ts.map +1 -1
  169. package/dist/ui/header.js +101 -36
  170. package/dist/ui/header.js.map +1 -1
  171. package/dist/ui/legend.d.ts +15 -0
  172. package/dist/ui/legend.d.ts.map +1 -0
  173. package/dist/ui/legend.js +68 -0
  174. package/dist/ui/legend.js.map +1 -0
  175. package/dist/ui/popovers.d.ts +3 -1
  176. package/dist/ui/popovers.d.ts.map +1 -1
  177. package/dist/ui/popovers.js +62 -23
  178. package/dist/ui/popovers.js.map +1 -1
  179. package/dist/ui/search.d.ts +13 -0
  180. package/dist/ui/search.d.ts.map +1 -0
  181. package/dist/ui/search.js +192 -0
  182. package/dist/ui/search.js.map +1 -0
  183. package/dist/ui/sidebar.d.ts +32 -17
  184. package/dist/ui/sidebar.d.ts.map +1 -1
  185. package/dist/ui/sidebar.js +327 -519
  186. package/dist/ui/sidebar.js.map +1 -1
  187. package/dist/ui/stage.d.ts +56 -1
  188. package/dist/ui/stage.d.ts.map +1 -1
  189. package/dist/ui/stage.js +244 -256
  190. package/dist/ui/stage.js.map +1 -1
  191. package/dist/ui/swatch.js +1 -1
  192. package/dist/ui/swatch.js.map +1 -1
  193. package/dist/ui/transport.d.ts +12 -0
  194. package/dist/ui/transport.d.ts.map +1 -0
  195. package/dist/ui/transport.js +198 -0
  196. package/dist/ui/transport.js.map +1 -0
  197. package/dist/ui/zoom.d.ts +15 -0
  198. package/dist/ui/zoom.d.ts.map +1 -0
  199. package/dist/ui/zoom.js +30 -0
  200. package/dist/ui/zoom.js.map +1 -0
  201. package/dist/view.d.ts +94 -0
  202. package/dist/view.d.ts.map +1 -0
  203. package/dist/view.js +441 -0
  204. package/dist/view.js.map +1 -0
  205. package/package.json +95 -12
  206. package/src/bind.ts +80 -0
  207. package/src/context.ts +106 -0
  208. package/src/controller.ts +52 -1630
  209. package/src/dom.ts +30 -0
  210. package/src/element.ts +386 -619
  211. package/src/elements/actions.ts +94 -0
  212. package/src/elements/bound.ts +247 -0
  213. package/src/elements/change-detail.ts +34 -0
  214. package/src/elements/change-list.ts +51 -0
  215. package/src/elements/filters.ts +34 -0
  216. package/src/elements/legend.ts +103 -0
  217. package/src/elements/minimap.ts +75 -0
  218. package/src/elements/root.ts +86 -0
  219. package/src/elements/search.ts +38 -0
  220. package/src/elements/stage-context.ts +34 -0
  221. package/src/elements/stage.ts +285 -0
  222. package/src/elements/title.ts +34 -0
  223. package/src/elements/transport.ts +39 -0
  224. package/src/elements/view-bound.ts +118 -0
  225. package/src/elements/view-dock.ts +43 -0
  226. package/src/elements/zoom.ts +38 -0
  227. package/src/i18n.ts +20 -0
  228. package/src/icons.ts +1 -0
  229. package/src/index.ts +350 -35
  230. package/src/keyboard.ts +188 -61
  231. package/src/layout-store.ts +71 -0
  232. package/src/panel-levels.ts +53 -0
  233. package/src/panels.ts +81 -0
  234. package/src/pipeline.ts +4 -1
  235. package/src/prefs.ts +12 -4
  236. package/src/react.ts +459 -0
  237. package/src/root-element.ts +624 -0
  238. package/src/root.ts +335 -0
  239. package/src/schedule.ts +43 -0
  240. package/src/session.ts +1375 -0
  241. package/src/styles.ts +396 -263
  242. package/src/theme.ts +170 -0
  243. package/src/topics.ts +37 -0
  244. package/src/ui/change-list.ts +405 -0
  245. package/src/ui/context.ts +36 -7
  246. package/src/ui/filters.ts +79 -0
  247. package/src/ui/header.ts +129 -58
  248. package/src/ui/legend.ts +112 -0
  249. package/src/ui/popovers.ts +68 -25
  250. package/src/ui/search.ts +225 -0
  251. package/src/ui/sidebar.ts +362 -639
  252. package/src/ui/stage.ts +267 -331
  253. package/src/ui/swatch.ts +1 -1
  254. package/src/ui/transport.ts +240 -0
  255. package/src/ui/zoom.ts +45 -0
  256. package/src/view.ts +516 -0
  257. package/vscode.html-custom-data.json +726 -0
@@ -0,0 +1,1237 @@
1
+ import { KgdiffError } from '@kgdiff/core';
2
+ import { buildScene, computeFocus, createViewState, createVisualResolver, frameBounds, getTheme, } from '@kgdiff/scene';
3
+ import { computeNow, computeStatus, loadCompute } from './compute-loader.js';
4
+ import { translate } from './i18n.js';
5
+ import { buildList, EMPTY_LIST, stepHunk } from './list-model.js';
6
+ import { readPayload, runLoad } from './pipeline.js';
7
+ import { LEGACY_CHANGES_MODE, toLocale, toMode, toPalette, toScope, toTheme } from './prefs.js';
8
+ import { ReplayClock } from './replay.js';
9
+ import { ReviewStore, reviewKey } from './review-store.js';
10
+ import { SearchIndex } from './search.js';
11
+ import { ALL_TOPICS } from './topics.js';
12
+ export { ALL_TOPICS } from './topics.js';
13
+ /** Spotlight emphasis restarts here when switching directly between two hunks. */
14
+ const SWITCH_EMPHASIS = 0.35;
15
+ /** Search hits kept for a query. */
16
+ export const SEARCH_LIMIT = 50;
17
+ class Superseded extends Error {
18
+ }
19
+ const ease = (u) => 1 - (1 - u) ** 3;
20
+ export class DiffSession {
21
+ status = 'idle';
22
+ /** Steps of the current load, in order; `step` is the running one. */
23
+ steps = [];
24
+ step = null;
25
+ error = null;
26
+ data = null;
27
+ layout = null;
28
+ scene = null;
29
+ resolver = null;
30
+ list = EMPTY_LIST;
31
+ mode = 'overlay';
32
+ scope = 'all';
33
+ themePreference = 'auto';
34
+ palette = 'default';
35
+ locale = 'en';
36
+ systemDark = true;
37
+ reducedMotion = false;
38
+ /** Preferences set explicitly by the embedder (a report's `view` does not override them). */
39
+ explicit = new Set();
40
+ selectedHunk = -1;
41
+ selectedNode = -1;
42
+ /** An edge emphasized from the detail panel (drawn as hovered). */
43
+ pinnedEdge = -1;
44
+ hoverNode = -1;
45
+ hoverEdge = -1;
46
+ hoverGroup = -1;
47
+ families = new Set();
48
+ /** The node search as typed; while it is not blank, `hits` replace the change list. */
49
+ query = '';
50
+ hits = [];
51
+ hops = 1;
52
+ expanded = new Set();
53
+ focus = null;
54
+ clock = null;
55
+ split = 0.5;
56
+ emphasis = 1;
57
+ #theme = null;
58
+ #themeKey = '';
59
+ #review = null;
60
+ #searchIndex = null;
61
+ #views = new Set();
62
+ #fadingHunk = -1;
63
+ #emphasisAnim = null;
64
+ #rafId = 0;
65
+ #lastTick = 0;
66
+ #frameDirty = false;
67
+ #followed = '';
68
+ #loadToken = 0;
69
+ #pendingHunk = null;
70
+ /** A finished replay starts again this long (ms) after its end; null: it plays once. */
71
+ #loopDelay = null;
72
+ /** When a finished looping replay starts again (`deps.now()` time), else NaN. */
73
+ #restartAt = Number.NaN;
74
+ /** Playback is held (pointer over the graph); `#resume`: it was playing and continues after. */
75
+ #held = false;
76
+ #resume = false;
77
+ #listeners = new Set();
78
+ #pendingTopics = new Set();
79
+ #flushQueued = false;
80
+ #destroyed = false;
81
+ #host;
82
+ #deps;
83
+ constructor(host, deps) {
84
+ this.#host = host;
85
+ this.#deps = deps;
86
+ }
87
+ // ─── Subscriptions ────────────────────────────────────────────────────────
88
+ subscribe(listener) {
89
+ this.#listeners.add(listener);
90
+ return () => this.#listeners.delete(listener);
91
+ }
92
+ /** @internal Queue a notification (views report what they derive, e.g. `overview`). */
93
+ notify(...topics) {
94
+ for (const t of topics)
95
+ this.#pendingTopics.add(t);
96
+ if (this.#flushQueued)
97
+ return;
98
+ this.#flushQueued = true;
99
+ queueMicrotask(() => this.flush());
100
+ }
101
+ /** Deliver pending notifications now (also used by tests). */
102
+ flush() {
103
+ this.#flushQueued = false;
104
+ if (this.#pendingTopics.size === 0)
105
+ return;
106
+ const topics = this.#pendingTopics;
107
+ this.#pendingTopics = new Set();
108
+ for (const fn of this.#listeners)
109
+ fn(topics);
110
+ }
111
+ /** Draw on `view` from now on; returns the function that stops it. */
112
+ addView(view) {
113
+ this.#views.add(view);
114
+ return () => this.#views.delete(view);
115
+ }
116
+ /** @internal The session's clock (`SessionDeps.now`), for its views. */
117
+ now() {
118
+ return this.#deps.now();
119
+ }
120
+ /** Fit what is drawn into every view (each frames it on its own canvas). */
121
+ fit(options) {
122
+ return Promise.all([...this.#views].map((v) => v.fit?.(options))).then(() => undefined);
123
+ }
124
+ /** Zoom every view one step in (1) or out (-1): the keyboard's `+` / `−`. */
125
+ zoom(direction) {
126
+ for (const v of this.#views)
127
+ v.zoom?.(direction);
128
+ }
129
+ // ─── Derived state ────────────────────────────────────────────────────────
130
+ get themeName() {
131
+ if (this.themePreference === 'auto')
132
+ return this.systemDark ? 'dark' : 'light';
133
+ return this.themePreference;
134
+ }
135
+ /** The theme to draw with: the built-in one for theme and palette, as the root's CSS says. */
136
+ get theme() {
137
+ const key = `${this.themeName}/${this.palette}`;
138
+ if (this.#theme === null || key !== this.#themeKey) {
139
+ this.#themeKey = key;
140
+ this.#theme = this.#resolveTheme();
141
+ }
142
+ return this.#theme;
143
+ }
144
+ #resolveTheme() {
145
+ let base;
146
+ try {
147
+ base = getTheme(this.themeName, this.palette);
148
+ }
149
+ catch {
150
+ base = getTheme('dark', 'default');
151
+ }
152
+ return this.#deps.theme?.(base) ?? base;
153
+ }
154
+ get diff() {
155
+ return this.data?.diff ?? null;
156
+ }
157
+ get hunkCount() {
158
+ return this.scene?.hunks.length ?? 0;
159
+ }
160
+ isReviewed(hunkId) {
161
+ return this.#review?.has(hunkId) ?? false;
162
+ }
163
+ reviewState() {
164
+ return this.#review?.state() ?? { reviewed: [], total: 0 };
165
+ }
166
+ search(query, limit) {
167
+ const diff = this.diff;
168
+ if (!diff)
169
+ return [];
170
+ this.#searchIndex ??= new SearchIndex(diff);
171
+ return this.#searchIndex.search(query, limit);
172
+ }
173
+ /** Search the nodes for `query`; delivered at once, so the results follow every keystroke. */
174
+ setQuery(query) {
175
+ this.query = query;
176
+ this.#runSearch();
177
+ this.notify('search');
178
+ this.flush();
179
+ }
180
+ #runSearch() {
181
+ const q = this.query.trim();
182
+ this.hits = q ? this.search(q, SEARCH_LIMIT) : [];
183
+ }
184
+ // ─── Loading ──────────────────────────────────────────────────────────────
185
+ async load(options) {
186
+ const text = (s) => typeof s === 'object' && s !== null && typeof s.text === 'string';
187
+ const parse = text(options?.before) || text(options?.after);
188
+ await this.#run(parse ? ['parse', 'match', 'layout', 'scene'] : ['match', 'layout', 'scene'], (onStep) => runLoad(options, onStep));
189
+ }
190
+ async loadPayload(payload) {
191
+ await this.#run(['match', 'layout', 'scene'], (onStep) => readPayload(payload, onStep));
192
+ }
193
+ async #run(planned, work) {
194
+ const token = ++this.#loadToken;
195
+ this.pause();
196
+ this.status = 'loading';
197
+ this.error = null;
198
+ this.steps = planned;
199
+ this.step = null;
200
+ this.notify('status');
201
+ const onStep = async (step) => {
202
+ if (token !== this.#loadToken || this.#destroyed)
203
+ throw new Superseded();
204
+ this.step = step;
205
+ this.notify('status');
206
+ this.flush();
207
+ await this.#deps.yieldToPaint();
208
+ if (token !== this.#loadToken || this.#destroyed)
209
+ throw new Superseded();
210
+ };
211
+ try {
212
+ const loaded = await work(onStep);
213
+ await onStep('scene');
214
+ this.#install(loaded);
215
+ }
216
+ catch (error) {
217
+ if (error instanceof Superseded) {
218
+ throw new DOMException('Superseded by a newer load.', 'AbortError');
219
+ }
220
+ const err = error instanceof Error ? error : new KgdiffError('invalid-input', String(error));
221
+ if (token === this.#loadToken)
222
+ this.fail(err);
223
+ throw err;
224
+ }
225
+ }
226
+ /** Show an error state (bad payload, failed load). */
227
+ fail(error) {
228
+ this.#loadToken++;
229
+ this.clock?.pause();
230
+ this.#restartAt = Number.NaN;
231
+ this.#resume = false;
232
+ this.status = 'error';
233
+ this.error = error;
234
+ this.step = null;
235
+ // Nothing stale stays on screen next to the error.
236
+ this.data = null;
237
+ this.layout = null;
238
+ this.scene = null;
239
+ this.resolver = null;
240
+ this.list = EMPTY_LIST;
241
+ this.clock = null;
242
+ this.focus = null;
243
+ this.#review = null;
244
+ this.#searchIndex = null;
245
+ this.#runSearch();
246
+ this.selectedHunk = this.selectedNode = this.pinnedEdge = -1;
247
+ this.notify('status', 'data', 'selection', 'filters', 'search', 'review', 'replay', 'focus');
248
+ this.#host.error(error);
249
+ }
250
+ #install(loaded) {
251
+ const { diff, layout, view } = loaded;
252
+ this.data = loaded;
253
+ this.layout = layout;
254
+ this.#searchIndex = null;
255
+ this.#runSearch();
256
+ this.families.clear();
257
+ this.expanded.clear();
258
+ this.hops = Math.max(0, Math.min(2, loaded.layoutOptions?.contextHops ?? 1));
259
+ this.selectedHunk = -1;
260
+ this.selectedNode = -1;
261
+ this.pinnedEdge = -1;
262
+ this.#fadingHunk = -1;
263
+ this.emphasis = 1;
264
+ this.#emphasisAnim = null;
265
+ this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
266
+ this.split = 0.5;
267
+ this.clock = null;
268
+ this.#followed = '';
269
+ if (view) {
270
+ if (view.theme && !this.explicit.has('theme'))
271
+ this.themePreference = view.theme;
272
+ if (view.palette && !this.explicit.has('palette'))
273
+ this.palette = view.palette;
274
+ if (view.locale && !this.explicit.has('locale'))
275
+ this.locale = view.locale;
276
+ }
277
+ const previousMode = this.mode;
278
+ const previousScope = this.scope;
279
+ if (!this.explicit.has('mode'))
280
+ this.mode = view?.mode ?? 'overlay';
281
+ if (layout.scope === 'changes') {
282
+ // Only the changes are laid out: there is no whole graph to show.
283
+ this.scope = 'changes';
284
+ }
285
+ else if (!this.explicit.has('scope')) {
286
+ const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
287
+ this.scope = view?.scope ?? (diff.nodes.length > threshold ? 'changes' : 'all');
288
+ }
289
+ this.#review = new ReviewStore(this.#deps.storage, reviewKey(diff.delta.base.hash, diff.delta.target.hash), diff.hunks.map((h) => h.id));
290
+ this.#rebuildScene();
291
+ this.status = 'ready';
292
+ this.step = null;
293
+ if (layout.scope === 'changes' && computeStatus() === 'unknown') {
294
+ // Learn early whether out-of-layout groups can be expanded (standalone reports).
295
+ loadCompute().then(() => this.notify('focus'), () => this.notify('focus'));
296
+ }
297
+ this.notify(...ALL_TOPICS);
298
+ // A new diff starts fitted, after the UI has shown its chrome (the notification above is
299
+ // delivered first) and once the canvas has a size.
300
+ for (const v of this.#views)
301
+ v.installed();
302
+ this.#host.ready(diff.stats);
303
+ if (this.mode !== previousMode || this.scope !== previousScope) {
304
+ this.#host.mode(this.mode, this.scope);
305
+ }
306
+ const pending = this.#pendingHunk;
307
+ this.#pendingHunk = null;
308
+ if (pending !== null)
309
+ this.selectHunkById(pending.id, { fly: pending.fly });
310
+ else
311
+ this.#autoplay();
312
+ }
313
+ /** (Re)build scene, resolver, list, clock and focus; selection is carried over by id. */
314
+ #rebuildScene() {
315
+ const data = this.data;
316
+ const layout = this.layout;
317
+ if (!data || !layout)
318
+ return;
319
+ const old = this.scene;
320
+ const keepHunk = old && this.selectedHunk >= 0 ? old.hunks[this.selectedHunk]?.id : undefined;
321
+ const keepNode = old && this.selectedNode >= 0 ? old.nodes[this.selectedNode]?.id : undefined;
322
+ const keepEdge = old && this.pinnedEdge >= 0 ? old.edges[this.pinnedEdge]?.id : undefined;
323
+ const scene = buildScene(data.diff, layout, { theme: this.theme, locale: this.locale });
324
+ this.scene = scene;
325
+ this.resolver = createVisualResolver(scene);
326
+ const prevClock = this.clock;
327
+ this.clock = new ReplayClock(scene.timeline);
328
+ if (prevClock) {
329
+ this.clock.setSpeed(prevClock.speed);
330
+ this.clock.seek(prevClock.t);
331
+ if (prevClock.playing)
332
+ this.clock.play();
333
+ }
334
+ this.selectedHunk = keepHunk !== undefined ? (scene.hunkIndex.get(keepHunk) ?? -1) : -1;
335
+ this.selectedNode = keepNode !== undefined ? (scene.nodeIndex.get(keepNode) ?? -1) : -1;
336
+ this.pinnedEdge = keepEdge !== undefined ? (scene.edgeIndex.get(keepEdge) ?? -1) : -1;
337
+ this.#fadingHunk = -1;
338
+ this.hoverNode = this.hoverEdge = this.hoverGroup = -1;
339
+ this.list = buildList(scene, this.families);
340
+ this.#updateFocus();
341
+ for (const v of this.#views)
342
+ v.sceneChanged();
343
+ this.invalidate();
344
+ }
345
+ #updateFocus() {
346
+ const scene = this.scene;
347
+ if (!scene || this.scope !== 'changes') {
348
+ this.focus = null;
349
+ return;
350
+ }
351
+ const expanded = new Set();
352
+ for (const id of this.expanded) {
353
+ const i = scene.nodeIndex.get(id);
354
+ if (i !== undefined)
355
+ expanded.add(i);
356
+ }
357
+ this.focus = computeFocus(scene, { hops: this.hops, expanded });
358
+ }
359
+ destroy() {
360
+ this.#destroyed = true;
361
+ this.sleep();
362
+ this.#listeners.clear();
363
+ }
364
+ // ─── Frame loop ───────────────────────────────────────────────────────────
365
+ /** Redraw on the next frame. */
366
+ invalidate() {
367
+ this.#frameDirty = true;
368
+ this.#schedule();
369
+ }
370
+ /** @internal Drop the pending frame (the views are gone); `invalidate` wakes the loop. */
371
+ sleep() {
372
+ if (this.#rafId)
373
+ this.#deps.cancelFrame(this.#rafId);
374
+ this.#rafId = 0;
375
+ }
376
+ #schedule() {
377
+ if (this.#rafId !== 0 || this.#destroyed)
378
+ return;
379
+ this.#rafId = this.#deps.requestFrame(this.#tick) || 0;
380
+ }
381
+ #tick = () => {
382
+ this.#rafId = 0;
383
+ if (this.#destroyed)
384
+ return;
385
+ const now = this.#deps.now();
386
+ const dt = this.#lastTick > 0 ? now - this.#lastTick : 0;
387
+ let animating = false;
388
+ const clock = this.clock;
389
+ if (clock?.playing && this.mode === 'replay') {
390
+ clock.advance(dt);
391
+ this.#frameDirty = true;
392
+ this.notify('replay');
393
+ this.#follow();
394
+ if (clock.playing)
395
+ animating = true;
396
+ else if (clock.finished)
397
+ this.#ended();
398
+ }
399
+ if (!Number.isNaN(this.#restartAt) && !this.#held) {
400
+ if (now >= this.#restartAt)
401
+ this.#loopAgain();
402
+ else
403
+ animating = true;
404
+ }
405
+ if (this.#emphasisAnim) {
406
+ const a = this.#emphasisAnim;
407
+ const u = a.duration > 0 ? Math.min(1, (now - a.start) / a.duration) : 1;
408
+ this.emphasis = a.from + (a.to - a.from) * ease(u);
409
+ this.#frameDirty = true;
410
+ if (u >= 1) {
411
+ this.#emphasisAnim = null;
412
+ if (this.#fadingHunk >= 0) {
413
+ this.#fadingHunk = -1;
414
+ this.emphasis = 1;
415
+ }
416
+ }
417
+ else
418
+ animating = true;
419
+ }
420
+ if (this.#frameDirty) {
421
+ this.#frameDirty = false;
422
+ for (const v of this.#views)
423
+ v.draw(now);
424
+ }
425
+ if (animating) {
426
+ this.#lastTick = now;
427
+ this.#schedule();
428
+ }
429
+ else
430
+ this.#lastTick = 0;
431
+ };
432
+ /** What a view draws for `layer` now. */
433
+ viewState(layer) {
434
+ const selected = this.selectedHunk >= 0 ? this.selectedHunk : this.#fadingHunk;
435
+ return createViewState({
436
+ layer,
437
+ t: this.clock?.t ?? 0,
438
+ selectedHunk: selected,
439
+ hoveredNode: this.hoverNode,
440
+ hoveredEdge: this.hoverEdge >= 0 ? this.hoverEdge : this.pinnedEdge,
441
+ selectedNode: this.selectedNode,
442
+ focus: this.focus,
443
+ families: this.families,
444
+ reducedMotion: this.reducedMotion,
445
+ emphasis: this.emphasis,
446
+ });
447
+ }
448
+ #startEmphasis(from) {
449
+ if (this.reducedMotion) {
450
+ this.emphasis = 1;
451
+ this.#emphasisAnim = null;
452
+ this.#fadingHunk = -1;
453
+ return;
454
+ }
455
+ this.emphasis = from;
456
+ this.#emphasisAnim = {
457
+ from,
458
+ to: 1,
459
+ start: this.#deps.now(),
460
+ duration: this.theme.motion.base,
461
+ };
462
+ }
463
+ #fadeOutEmphasis(hunk) {
464
+ if (this.reducedMotion || hunk < 0) {
465
+ this.#fadingHunk = -1;
466
+ this.#emphasisAnim = null;
467
+ this.emphasis = 1;
468
+ return;
469
+ }
470
+ this.#fadingHunk = hunk;
471
+ this.#emphasisAnim = {
472
+ from: this.emphasis,
473
+ to: 0,
474
+ start: this.#deps.now(),
475
+ duration: this.theme.motion.base,
476
+ };
477
+ }
478
+ #drawnChanged() {
479
+ for (const v of this.#views)
480
+ v.drawnChanged();
481
+ }
482
+ // ─── View: mode × scope ───────────────────────────────────────────────────
483
+ /** Whether the scope is fixed to `changes` (the layout covers only the changes). */
484
+ get scopeLocked() {
485
+ return this.layout?.scope === 'changes';
486
+ }
487
+ /** Switch how the versions are compared. The legacy `changes` means overlay + `changes` scope. */
488
+ setMode(value) {
489
+ if (value === LEGACY_CHANGES_MODE) {
490
+ this.setView('overlay', 'changes');
491
+ return;
492
+ }
493
+ const mode = toMode(value);
494
+ if (mode !== null)
495
+ this.setView(mode, this.scope);
496
+ }
497
+ /** Show the whole graph or only the changes, in any mode. */
498
+ setScope(value) {
499
+ const scope = toScope(value);
500
+ if (scope !== null)
501
+ this.setView(this.mode, scope);
502
+ }
503
+ /** Toggle the `changes` scope (key `4`); says why when the scope is locked. */
504
+ toggleScope() {
505
+ if (this.scopeLocked) {
506
+ this.#host.announce(translate(this.locale, 'scope.locked'));
507
+ return;
508
+ }
509
+ this.setScope(this.scope === 'changes' ? 'all' : 'changes');
510
+ }
511
+ /** Set mode and scope together (one notification, one `kgdiff-mode` event). */
512
+ setView(mode, scope) {
513
+ const nextScope = this.scopeLocked ? 'changes' : scope;
514
+ const modeChanged = mode !== this.mode;
515
+ const scopeChanged = nextScope !== this.scope;
516
+ if (!modeChanged && !scopeChanged)
517
+ return;
518
+ this.mode = mode;
519
+ this.scope = nextScope;
520
+ if (mode !== 'replay') {
521
+ this.clock?.pause();
522
+ this.#restartAt = Number.NaN;
523
+ this.#resume = false;
524
+ }
525
+ this.#followed = '';
526
+ if (scopeChanged)
527
+ this.#updateFocus();
528
+ this.#drawnChanged();
529
+ this.notify('mode', 'focus', 'replay');
530
+ this.invalidate();
531
+ if (this.status === 'ready') {
532
+ this.#host.mode(mode, nextScope);
533
+ const parts = [];
534
+ if (modeChanged) {
535
+ parts.push(translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }));
536
+ }
537
+ if (scopeChanged) {
538
+ parts.push(translate(this.locale, nextScope === 'changes' ? 'scope.on' : 'scope.off'));
539
+ }
540
+ this.#host.announce(parts.join(' · '));
541
+ }
542
+ // Switching to replay starts a looping replay; toggling the scope leaves playback alone.
543
+ if (modeChanged && mode === 'replay')
544
+ this.#autoplay();
545
+ }
546
+ // ─── Selection ────────────────────────────────────────────────────────────
547
+ selectHunkById(id, options = {}) {
548
+ if (id === null) {
549
+ this.#pendingHunk = null;
550
+ this.clearSelection();
551
+ return;
552
+ }
553
+ const scene = this.scene;
554
+ if (!scene || this.status !== 'ready') {
555
+ this.#pendingHunk = { id, fly: options.fly ?? true };
556
+ return;
557
+ }
558
+ const h = scene.hunkIndex.get(id);
559
+ if (h !== undefined)
560
+ this.selectHunk(h, options);
561
+ }
562
+ /**
563
+ * Select hunk `h` (scene index; -1 clears). `node` focuses one of its nodes in the detail
564
+ * panel. `fly` (default true) brings it into view. In replay, seeks to the end of its window.
565
+ */
566
+ selectHunk(h, options = {}) {
567
+ const scene = this.scene;
568
+ if (!scene)
569
+ return;
570
+ if (h < 0 || h >= scene.hunks.length) {
571
+ this.clearSelection();
572
+ return;
573
+ }
574
+ const node = options.node ?? -1;
575
+ const prev = this.selectedHunk;
576
+ const changed = prev !== h || this.selectedNode !== node;
577
+ this.selectedHunk = h;
578
+ this.selectedNode = node;
579
+ this.pinnedEdge = -1;
580
+ if (prev !== h) {
581
+ this.#fadingHunk = -1;
582
+ this.#startEmphasis(prev >= 0 ? SWITCH_EMPHASIS : 0);
583
+ if (this.mode === 'replay' && this.clock) {
584
+ const end = this.clock.windowEnd(h);
585
+ this.clock.pause();
586
+ this.#restartAt = Number.NaN;
587
+ this.#resume = false;
588
+ if (end !== null)
589
+ this.clock.seek(end);
590
+ this.#followed = '';
591
+ this.notify('replay');
592
+ }
593
+ }
594
+ if (options.fly ?? true)
595
+ this.flyToHunk(h);
596
+ this.invalidate();
597
+ if (!changed)
598
+ return;
599
+ this.notify('selection');
600
+ const hunk = scene.hunks[h];
601
+ if (!hunk)
602
+ return;
603
+ const nodeId = node >= 0 ? (scene.nodes[node]?.id ?? null) : null;
604
+ this.#host.select(hunk.id, nodeId);
605
+ if (prev !== h) {
606
+ const rank = this.list.rank.get(h);
607
+ this.#host.announce(translate(this.locale, 'announce.change', {
608
+ i: (rank ?? h) + 1,
609
+ n: rank !== undefined ? this.list.order.length : scene.hunks.length,
610
+ title: hunk.title,
611
+ }));
612
+ }
613
+ }
614
+ /** Select a node: its hunk when it has one, else the node itself. */
615
+ selectNode(i, options = {}) {
616
+ const scene = this.scene;
617
+ const n = scene?.nodes[i];
618
+ if (!scene || !n)
619
+ return;
620
+ if (n.hunk >= 0) {
621
+ this.selectHunk(n.hunk, { ...options, node: i });
622
+ return;
623
+ }
624
+ const prev = this.selectedHunk;
625
+ if (prev < 0 && this.selectedNode === i) {
626
+ if (options.fly)
627
+ this.flyToNode(i);
628
+ return;
629
+ }
630
+ this.selectedHunk = -1;
631
+ this.selectedNode = i;
632
+ this.pinnedEdge = -1;
633
+ if (prev >= 0)
634
+ this.#fadeOutEmphasis(prev);
635
+ if (options.fly)
636
+ this.flyToNode(i);
637
+ this.invalidate();
638
+ this.notify('selection');
639
+ this.#host.select(null, n.id);
640
+ this.#host.announce(translate(this.locale, 'announce.node', {
641
+ label: n.label,
642
+ status: translate(this.locale, `status.${n.status}`),
643
+ }));
644
+ }
645
+ clearSelection() {
646
+ if (this.selectedHunk < 0 && this.selectedNode < 0 && this.pinnedEdge < 0)
647
+ return;
648
+ const prev = this.selectedHunk;
649
+ this.selectedHunk = -1;
650
+ this.selectedNode = -1;
651
+ this.pinnedEdge = -1;
652
+ this.#fadeOutEmphasis(prev);
653
+ this.invalidate();
654
+ this.notify('selection');
655
+ this.#host.select(null, null);
656
+ this.#host.announce(translate(this.locale, 'announce.cleared'));
657
+ }
658
+ next() {
659
+ this.#stepSelection(1);
660
+ }
661
+ prev() {
662
+ this.#stepSelection(-1);
663
+ }
664
+ #stepSelection(direction) {
665
+ const h = stepHunk(this.list, this.selectedHunk, direction);
666
+ if (h >= 0)
667
+ this.selectHunk(h);
668
+ }
669
+ first() {
670
+ const h = this.list.order[0];
671
+ if (h !== undefined)
672
+ this.selectHunk(h);
673
+ }
674
+ last() {
675
+ const h = this.list.order[this.list.order.length - 1];
676
+ if (h !== undefined)
677
+ this.selectHunk(h);
678
+ }
679
+ /** Emphasize an edge (from the detail panel) and fly to it. */
680
+ pinEdge(j) {
681
+ const scene = this.scene;
682
+ const e = scene?.edges[j];
683
+ if (!scene || !e)
684
+ return;
685
+ this.pinnedEdge = j;
686
+ const ids = [e.s, e.t, e.sb, e.tb];
687
+ let x0 = Number.POSITIVE_INFINITY;
688
+ let y0 = Number.POSITIVE_INFINITY;
689
+ let x1 = Number.NEGATIVE_INFINITY;
690
+ let y1 = Number.NEGATIVE_INFINITY;
691
+ for (const i of ids) {
692
+ const n = scene.nodes[i];
693
+ if (!n)
694
+ continue;
695
+ x0 = Math.min(x0, n.x - n.r);
696
+ y0 = Math.min(y0, n.y - n.r);
697
+ x1 = Math.max(x1, n.x + n.r);
698
+ y1 = Math.max(y1, n.y + n.r);
699
+ }
700
+ if (x0 <= x1)
701
+ this.#show(padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }));
702
+ this.invalidate();
703
+ }
704
+ setHover(node, edge, group) {
705
+ if (node === this.hoverNode && edge === this.hoverEdge && group === this.hoverGroup)
706
+ return;
707
+ this.hoverNode = node;
708
+ this.hoverEdge = edge;
709
+ this.hoverGroup = group;
710
+ this.notify('hover');
711
+ this.invalidate();
712
+ }
713
+ /** Clear hover state (pointer left the canvas). */
714
+ clearHover() {
715
+ this.setHover(-1, -1, -1);
716
+ }
717
+ // ─── Bringing things into view ────────────────────────────────────────────
718
+ #show(bounds) {
719
+ for (const v of this.#views)
720
+ v.show(bounds);
721
+ }
722
+ /** “Change 3 of 23: …” for a replayed hunk, “Restructured: 722 changes” for a chapter. */
723
+ #announceTarget(kind, index) {
724
+ const scene = this.scene;
725
+ const clock = this.clock;
726
+ if (!scene || !clock)
727
+ return;
728
+ if (kind === 'chapter') {
729
+ const ch = scene.timeline.chapters[index];
730
+ if (!ch)
731
+ return;
732
+ this.#host.announce(translate(this.locale, 'announce.chapter', {
733
+ phase: translate(this.locale, `phase.${ch.phase}`),
734
+ n: ch.hunks.length,
735
+ }));
736
+ return;
737
+ }
738
+ const hunk = scene.hunks[index];
739
+ const order = clock.timeline.windows.findIndex((w) => w.hunk === index);
740
+ if (!hunk || order < 0)
741
+ return;
742
+ this.#host.announce(translate(this.locale, 'announce.change', {
743
+ i: order + 1,
744
+ n: clock.total,
745
+ title: hunk.title,
746
+ }));
747
+ }
748
+ /** Replay follows `hunks`: the views bring them into view unless they mostly are. */
749
+ #followHunks(hunks) {
750
+ const scene = this.scene;
751
+ if (!scene || hunks.length === 0)
752
+ return;
753
+ const bounds = frameBounds(scene, hunks);
754
+ for (const v of this.#views)
755
+ v.follow(bounds);
756
+ }
757
+ /** Replay: follow (and announce) the playing hunk / chapter when it changes. */
758
+ #follow() {
759
+ const scene = this.scene;
760
+ const clock = this.clock;
761
+ if (!scene || !clock)
762
+ return;
763
+ const target = clock.activeTarget();
764
+ if (target.index < 0)
765
+ return;
766
+ const key = `${target.kind}:${target.index}`;
767
+ if (key === this.#followed)
768
+ return;
769
+ this.#followed = key;
770
+ this.#announceTarget(target.kind, target.index);
771
+ this.#followHunks(target.kind === 'chapter'
772
+ ? (scene.timeline.chapters[target.index]?.hunks ?? [])
773
+ : [target.index]);
774
+ }
775
+ #hunkBounds(h) {
776
+ const scene = this.scene;
777
+ return scene?.hunks[h] ? frameBounds(scene, [h]) : null;
778
+ }
779
+ #nodeBounds(i) {
780
+ const n = this.scene?.nodes[i];
781
+ return n ? padRect({ x: n.x - n.r, y: n.y - n.r, width: 2 * n.r, height: 2 * n.r }) : null;
782
+ }
783
+ /** Where the current selection is (world coordinates), or null. */
784
+ selectionBounds() {
785
+ if (this.selectedHunk >= 0)
786
+ return this.#hunkBounds(this.selectedHunk);
787
+ if (this.selectedNode >= 0)
788
+ return this.#nodeBounds(this.selectedNode);
789
+ return null;
790
+ }
791
+ flyToHunk(h) {
792
+ const b = this.#hunkBounds(h);
793
+ if (b)
794
+ this.#show(b);
795
+ }
796
+ flyToNode(i) {
797
+ const b = this.#nodeBounds(i);
798
+ if (b)
799
+ this.#show(b);
800
+ }
801
+ flyToSelection() {
802
+ const b = this.selectionBounds();
803
+ if (b)
804
+ this.#show(b);
805
+ }
806
+ // ─── Filters, focus ───────────────────────────────────────────────────────
807
+ toggleFamily(family) {
808
+ if (this.families.has(family))
809
+ this.families.delete(family);
810
+ else
811
+ this.families.add(family);
812
+ this.#filtersChanged();
813
+ }
814
+ /** Replace the family filter (empty = all). Setting the current set again clears it. */
815
+ setFamilies(families) {
816
+ const same = families.length === this.families.size && families.every((f) => this.families.has(f));
817
+ this.families.clear();
818
+ if (!same)
819
+ for (const f of families)
820
+ this.families.add(f);
821
+ this.#filtersChanged();
822
+ }
823
+ #filtersChanged() {
824
+ if (this.scene)
825
+ this.list = buildList(this.scene, this.families);
826
+ this.#drawnChanged();
827
+ this.notify('filters');
828
+ this.invalidate();
829
+ }
830
+ setHops(hops) {
831
+ const h = Math.max(0, Math.min(2, Math.round(hops)));
832
+ if (h === this.hops)
833
+ return;
834
+ this.hops = h;
835
+ this.#focusChanged();
836
+ }
837
+ /**
838
+ * Expand collapsed group `index` of the current focus: its in-scene members become visible
839
+ * and, for a `changes`-scoped layout, the neighbours left out of the layout are placed with
840
+ * `extendLayout` (nothing already placed moves) and the scene is rebuilt.
841
+ */
842
+ expandGroup(index) {
843
+ const group = this.focus?.collapsed[index];
844
+ const scene = this.scene;
845
+ if (!group || !scene)
846
+ return;
847
+ const ids = group.nodes.map((i) => scene.nodes[i]?.id).filter(isString);
848
+ if (!this.canExpand(index)) {
849
+ // Without the layout engine only the members already laid out can be shown.
850
+ if (ids.length > 0)
851
+ void this.#expandIds(ids, []);
852
+ return;
853
+ }
854
+ const owner = group.id.startsWith('near:')
855
+ ? group.id.slice(5)
856
+ : group.id.startsWith('component:')
857
+ ? group.id.slice(10)
858
+ : null;
859
+ void this.#expandIds(ids, owner !== null ? [owner, ...ids] : ids);
860
+ }
861
+ /**
862
+ * The collapsed group nearest the selection (else the view center), or -1. Groups that can
863
+ * be expanded at least partly come first.
864
+ */
865
+ nearestGroup() {
866
+ const groups = this.focus?.collapsed ?? [];
867
+ if (groups.length === 0)
868
+ return -1;
869
+ const sel = this.selectionBounds();
870
+ let view = null;
871
+ for (const v of this.#views)
872
+ view ??= v.viewport();
873
+ const at = sel ?? view ?? this.scene?.bounds;
874
+ const cx = at ? at.x + at.width / 2 : 0;
875
+ const cy = at ? at.y + at.height / 2 : 0;
876
+ let best = -1;
877
+ let bestScore = Number.POSITIVE_INFINITY;
878
+ groups.forEach((g, i) => {
879
+ const usable = this.canExpand(i) || g.nodes.length > 0;
880
+ const score = Math.hypot(g.x - cx, g.y - cy) + (usable ? 0 : 1e12);
881
+ if (score < bestScore) {
882
+ bestScore = score;
883
+ best = i;
884
+ }
885
+ });
886
+ return best;
887
+ }
888
+ /** Expand the group nearest the selection (keyboard `e`); announces what happened. */
889
+ expandNearest() {
890
+ const i = this.nearestGroup();
891
+ const group = i >= 0 ? this.focus?.collapsed[i] : undefined;
892
+ if (!group || (!this.canExpand(i) && group.nodes.length === 0)) {
893
+ this.#host.announce(translate(this.locale, group ? 'focus.expandUnavailable' : 'announce.nothingHidden'));
894
+ return;
895
+ }
896
+ this.expandGroup(i);
897
+ this.#host.announce(translate(this.locale, 'announce.expanded', { label: group.label }));
898
+ }
899
+ /**
900
+ * Whether collapsed group `index` can be expanded completely: always, unless some members lie
901
+ * outside a `changes`-scoped layout and the layout engine is known to be unavailable
902
+ * (standalone report without the compute add-on).
903
+ */
904
+ canExpand(index) {
905
+ const group = this.focus?.collapsed[index];
906
+ if (!group)
907
+ return false;
908
+ const outside = this.layout?.scope === 'changes' && group.count > group.nodes.length;
909
+ return !outside || computeStatus() !== 'unavailable';
910
+ }
911
+ /**
912
+ * Make `ids` visible, placing those outside the layout, plus the out-of-layout neighbours of
913
+ * `neighboursOf`. Resolves once done (the layout engine may load lazily first).
914
+ */
915
+ #expandIds(ids, neighboursOf) {
916
+ const data = this.data;
917
+ const layout = this.layout;
918
+ if (!data || !layout)
919
+ return Promise.resolve();
920
+ for (const id of ids)
921
+ this.expanded.add(id);
922
+ const missing = new Set();
923
+ if (layout.scope === 'changes') {
924
+ for (const id of ids)
925
+ if (!layout.positions.has(id))
926
+ missing.add(id);
927
+ for (const id of neighboursOf) {
928
+ for (const e of data.diff.index.edgesOf(id)) {
929
+ for (const end of [e.source, e.target, e.sourceBefore, e.targetBefore]) {
930
+ if (end !== undefined && !layout.positions.has(end))
931
+ missing.add(end);
932
+ }
933
+ }
934
+ }
935
+ }
936
+ if (missing.size === 0) {
937
+ this.#focusChanged();
938
+ return Promise.resolve();
939
+ }
940
+ const place = () => {
941
+ const compute = computeNow();
942
+ const current = this.layout;
943
+ if (!compute || !current || this.data !== data)
944
+ return;
945
+ const todo = [...missing].filter((id) => !current.positions.has(id));
946
+ if (todo.length > 0) {
947
+ this.layout = compute.extendLayout(data.diff, current, todo, data.layoutOptions);
948
+ for (const id of todo)
949
+ this.expanded.add(id);
950
+ this.#rebuildScene();
951
+ this.notify('data', 'selection', 'replay');
952
+ }
953
+ this.#focusChanged();
954
+ };
955
+ if (computeNow()) {
956
+ place();
957
+ return Promise.resolve();
958
+ }
959
+ // Standalone reports load the layout engine on demand.
960
+ return loadCompute().then(place, () => this.#focusChanged());
961
+ }
962
+ #focusChanged() {
963
+ this.#updateFocus();
964
+ this.#drawnChanged();
965
+ this.notify('focus');
966
+ this.invalidate();
967
+ }
968
+ /** Bring a union node into the scene and the focus (search results), then select it. */
969
+ revealNode(id) {
970
+ if (!this.data || !this.scene)
971
+ return Promise.resolve();
972
+ const select = () => {
973
+ const i = this.scene?.nodeIndex.get(id);
974
+ if (i !== undefined)
975
+ this.selectNode(i, { fly: true });
976
+ };
977
+ if (this.scene.nodeIndex.has(id) && this.#isVisible(id)) {
978
+ select();
979
+ return Promise.resolve();
980
+ }
981
+ return this.#expandIds([id], []).then(select);
982
+ }
983
+ #isVisible(id) {
984
+ const i = this.scene?.nodeIndex.get(id);
985
+ return i !== undefined && (this.focus === null || this.focus.nodes[i] === 1);
986
+ }
987
+ // ─── Replay ───────────────────────────────────────────────────────────────
988
+ play() {
989
+ const clock = this.clock;
990
+ if (!clock || this.status !== 'ready')
991
+ return;
992
+ if (this.mode !== 'replay')
993
+ this.setMode('replay');
994
+ this.#restartAt = Number.NaN;
995
+ this.#resume = false;
996
+ clock.play();
997
+ this.#followed = '';
998
+ this.#lastTick = 0;
999
+ this.notify('replay');
1000
+ this.invalidate();
1001
+ }
1002
+ /** Pause; a looping replay stays paused (until `play`, a new diff or loop turned on again). */
1003
+ pause() {
1004
+ this.#restartAt = Number.NaN;
1005
+ this.#resume = false;
1006
+ if (!this.clock?.playing)
1007
+ return;
1008
+ this.clock.pause();
1009
+ this.notify('replay');
1010
+ this.invalidate();
1011
+ }
1012
+ togglePlay() {
1013
+ if (this.clock?.playing)
1014
+ this.pause();
1015
+ else
1016
+ this.play();
1017
+ }
1018
+ /**
1019
+ * Play the replay over and over: `delay` ms after it ends it starts again; null plays it
1020
+ * once. Turning the loop on starts a replay that is shown but not playing.
1021
+ */
1022
+ setLoop(delay) {
1023
+ const was = this.#loopDelay;
1024
+ this.#loopDelay = delay === null ? null : Number.isFinite(delay) ? Math.max(0, delay) : 0;
1025
+ if (this.#loopDelay === null)
1026
+ this.#restartAt = Number.NaN;
1027
+ else if (was === null)
1028
+ this.#autoplay();
1029
+ }
1030
+ get loopDelay() {
1031
+ return this.#loopDelay;
1032
+ }
1033
+ /**
1034
+ * Hold playback while the pointer rests on the graph (`pause-on-hover`): a playing replay
1035
+ * pauses and continues on release; a pending loop restart waits for the release, then the
1036
+ * full loop delay.
1037
+ */
1038
+ hold(held) {
1039
+ if (held === this.#held)
1040
+ return;
1041
+ this.#held = held;
1042
+ const clock = this.clock;
1043
+ if (held) {
1044
+ if (!clock?.playing)
1045
+ return;
1046
+ clock.pause();
1047
+ this.#resume = true;
1048
+ this.notify('replay');
1049
+ this.invalidate();
1050
+ return;
1051
+ }
1052
+ if (!Number.isNaN(this.#restartAt) && this.#loopDelay !== null) {
1053
+ this.#restartAt = this.#deps.now() + this.#loopDelay;
1054
+ this.#schedule();
1055
+ }
1056
+ if (this.#resume)
1057
+ this.play();
1058
+ }
1059
+ /** Looping and showing a replay that is not playing: start it (or after the hold). */
1060
+ #autoplay() {
1061
+ if (this.#loopDelay === null || this.status !== 'ready' || this.mode !== 'replay')
1062
+ return;
1063
+ if (!this.clock || this.clock.playing || !Number.isNaN(this.#restartAt))
1064
+ return;
1065
+ if (this.#held)
1066
+ this.#resume = true;
1067
+ else
1068
+ this.play();
1069
+ }
1070
+ /** Playback reached the end: announce it, tell the host and arm the loop restart. */
1071
+ #ended() {
1072
+ this.#host.announce(translate(this.locale, 'announce.finished'));
1073
+ const loop = this.#loopDelay !== null;
1074
+ // Armed first, so a `kgdiff-replay-end` listener can still cancel it (pause, loop off).
1075
+ if (loop)
1076
+ this.#restartAt = this.#deps.now() + (this.#loopDelay ?? 0);
1077
+ this.#host.replayEnd(loop);
1078
+ }
1079
+ #loopAgain() {
1080
+ this.#restartAt = Number.NaN;
1081
+ for (const v of this.#views)
1082
+ v.looped();
1083
+ this.clock?.seek(0);
1084
+ this.play();
1085
+ }
1086
+ seek(t) {
1087
+ const clock = this.clock;
1088
+ if (!clock)
1089
+ return;
1090
+ this.#restartAt = Number.NaN;
1091
+ clock.seek(t);
1092
+ this.notify('replay');
1093
+ this.#follow();
1094
+ this.invalidate();
1095
+ }
1096
+ stepReplay(direction) {
1097
+ const clock = this.clock;
1098
+ if (!clock)
1099
+ return;
1100
+ clock.step(direction);
1101
+ this.#restartAt = Number.NaN;
1102
+ this.#resume = false;
1103
+ this.#followed = '';
1104
+ // Follow (and announce) the hunk that just completed: stops sit at window ends.
1105
+ const w = clock.timeline.windows[clock.position() - 1];
1106
+ if (w) {
1107
+ if (clock.timeline.mode === 'chapters')
1108
+ this.#announceTarget('chapter', w.chapter);
1109
+ else
1110
+ this.#announceTarget('hunk', w.hunk);
1111
+ this.#followHunks(clock.timeline.mode === 'chapters'
1112
+ ? (clock.timeline.chapters[w.chapter]?.hunks ?? [w.hunk])
1113
+ : [w.hunk]);
1114
+ }
1115
+ this.notify('replay');
1116
+ this.invalidate();
1117
+ }
1118
+ restart() {
1119
+ this.clock?.seek(0);
1120
+ this.play();
1121
+ }
1122
+ cycleSpeed() {
1123
+ this.clock?.cycleSpeed();
1124
+ this.notify('replay');
1125
+ }
1126
+ setSpeed(speed) {
1127
+ this.clock?.setSpeed(speed);
1128
+ this.notify('replay');
1129
+ }
1130
+ // ─── Swipe ────────────────────────────────────────────────────────────────
1131
+ setSplit(split) {
1132
+ const s = Number.isFinite(split) ? Math.min(1, Math.max(0, split)) : 0.5;
1133
+ if (s === this.split)
1134
+ return;
1135
+ this.split = s;
1136
+ this.notify('swipe');
1137
+ if (this.mode === 'swipe')
1138
+ this.invalidate();
1139
+ }
1140
+ // ─── Review ───────────────────────────────────────────────────────────────
1141
+ setReviewed(hunkId, reviewed) {
1142
+ const review = this.#review;
1143
+ if (!review?.setReviewed(hunkId, reviewed))
1144
+ return;
1145
+ const progress = review.state();
1146
+ this.notify('review');
1147
+ this.#host.review(hunkId, reviewed, progress);
1148
+ const text = translate(this.locale, 'review.progress', {
1149
+ done: progress.reviewed.length,
1150
+ total: progress.total,
1151
+ });
1152
+ this.#host.announce(translate(this.locale, reviewed ? 'announce.reviewed' : 'announce.unreviewed', {
1153
+ progress: text,
1154
+ }));
1155
+ }
1156
+ /** Another tab changed review progress (`storage` event): re-read it. */
1157
+ syncReview(key) {
1158
+ if (this.#review?.reload(key))
1159
+ this.notify('review');
1160
+ }
1161
+ toggleReviewed(hunkId) {
1162
+ const id = hunkId ?? (this.selectedHunk >= 0 ? this.scene?.hunks[this.selectedHunk]?.id : undefined);
1163
+ if (id !== undefined)
1164
+ this.setReviewed(id, !this.isReviewed(id));
1165
+ }
1166
+ // ─── Appearance ───────────────────────────────────────────────────────────
1167
+ #appearance(apply) {
1168
+ const name = this.themeName;
1169
+ const palette = this.palette;
1170
+ const locale = this.locale;
1171
+ apply();
1172
+ const themeChanged = name !== this.themeName || palette !== this.palette;
1173
+ const localeChanged = locale !== this.locale;
1174
+ if (themeChanged || localeChanged)
1175
+ this.#rethemed();
1176
+ if (themeChanged)
1177
+ this.notify('theme');
1178
+ if (localeChanged)
1179
+ this.notify('locale', 'data');
1180
+ }
1181
+ /** Draw with the current theme and locale: rebuild the scene, or re-theme the views. */
1182
+ #rethemed() {
1183
+ if (this.data)
1184
+ this.#rebuildScene();
1185
+ else
1186
+ for (const v of this.#views)
1187
+ v.sceneChanged();
1188
+ }
1189
+ /**
1190
+ * @internal Read the theme again (`SessionDeps.theme`: the root's CSS tokens changed, e.g.
1191
+ * the host overrode one); redraws only when the result changed.
1192
+ */
1193
+ refreshTheme() {
1194
+ const next = this.#resolveTheme();
1195
+ if (next === this.#theme)
1196
+ return;
1197
+ this.#theme = next;
1198
+ this.#themeKey = `${this.themeName}/${this.palette}`;
1199
+ this.#rethemed();
1200
+ this.notify('theme');
1201
+ }
1202
+ setThemePreference(pref) {
1203
+ this.#appearance(() => {
1204
+ this.themePreference = toTheme(pref) ?? 'auto';
1205
+ });
1206
+ this.notify('theme');
1207
+ }
1208
+ setPalette(palette) {
1209
+ this.#appearance(() => {
1210
+ this.palette = toPalette(palette) ?? 'default';
1211
+ });
1212
+ }
1213
+ setLocale(locale) {
1214
+ this.#appearance(() => {
1215
+ this.locale = toLocale(locale) ?? 'en';
1216
+ });
1217
+ }
1218
+ setSystemDark(dark) {
1219
+ this.#appearance(() => {
1220
+ this.systemDark = dark;
1221
+ });
1222
+ }
1223
+ setReducedMotion(reduced) {
1224
+ this.reducedMotion = reduced;
1225
+ this.invalidate();
1226
+ }
1227
+ }
1228
+ function isString(x) {
1229
+ return x !== undefined;
1230
+ }
1231
+ /** Pad a small rect so a node is shown with its neighbourhood. */
1232
+ export function padRect(r, min = 200, ratio = 1.4) {
1233
+ const w = Math.max(min, r.width * ratio);
1234
+ const h = Math.max(min, r.height * ratio);
1235
+ return { x: r.x + r.width / 2 - w / 2, y: r.y + r.height / 2 - h / 2, width: w, height: h };
1236
+ }
1237
+ //# sourceMappingURL=session.js.map