@kgdiff/viewer 0.3.0 → 0.4.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 (73) hide show
  1. package/README.md +9 -8
  2. package/dist/controller.d.ts +23 -7
  3. package/dist/controller.d.ts.map +1 -1
  4. package/dist/controller.js +119 -14
  5. package/dist/controller.js.map +1 -1
  6. package/dist/element.d.ts +6 -3
  7. package/dist/element.d.ts.map +1 -1
  8. package/dist/element.js +68 -19
  9. package/dist/element.js.map +1 -1
  10. package/dist/hash-sync.d.ts +4 -2
  11. package/dist/hash-sync.d.ts.map +1 -1
  12. package/dist/hash-sync.js +21 -5
  13. package/dist/hash-sync.js.map +1 -1
  14. package/dist/i18n.d.ts +7 -7
  15. package/dist/i18n.d.ts.map +1 -1
  16. package/dist/i18n.js +14 -14
  17. package/dist/i18n.js.map +1 -1
  18. package/dist/index.d.ts +21 -8
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/keyboard.d.ts +6 -4
  22. package/dist/keyboard.d.ts.map +1 -1
  23. package/dist/keyboard.js +6 -5
  24. package/dist/keyboard.js.map +1 -1
  25. package/dist/prefs.d.ts +7 -1
  26. package/dist/prefs.d.ts.map +1 -1
  27. package/dist/prefs.js +15 -3
  28. package/dist/prefs.js.map +1 -1
  29. package/dist/standalone/kgdiff-viewer.js +4 -4
  30. package/dist/styles.d.ts.map +1 -1
  31. package/dist/styles.js +69 -63
  32. package/dist/styles.js.map +1 -1
  33. package/dist/ui/context.d.ts +2 -0
  34. package/dist/ui/context.d.ts.map +1 -1
  35. package/dist/ui/context.js.map +1 -1
  36. package/dist/ui/dock.d.ts +17 -0
  37. package/dist/ui/dock.d.ts.map +1 -0
  38. package/dist/ui/dock.js +151 -0
  39. package/dist/ui/dock.js.map +1 -0
  40. package/dist/ui/header.d.ts +7 -2
  41. package/dist/ui/header.d.ts.map +1 -1
  42. package/dist/ui/header.js +24 -133
  43. package/dist/ui/header.js.map +1 -1
  44. package/dist/ui/popovers.d.ts.map +1 -1
  45. package/dist/ui/popovers.js +3 -2
  46. package/dist/ui/popovers.js.map +1 -1
  47. package/dist/ui/sidebar.d.ts.map +1 -1
  48. package/dist/ui/sidebar.js +2 -1
  49. package/dist/ui/sidebar.js.map +1 -1
  50. package/dist/ui/stage.d.ts +4 -0
  51. package/dist/ui/stage.d.ts.map +1 -1
  52. package/dist/ui/stage.js +41 -94
  53. package/dist/ui/stage.js.map +1 -1
  54. package/dist/ui/swatch.d.ts +7 -0
  55. package/dist/ui/swatch.d.ts.map +1 -0
  56. package/dist/ui/swatch.js +26 -0
  57. package/dist/ui/swatch.js.map +1 -0
  58. package/package.json +6 -6
  59. package/src/controller.ts +131 -20
  60. package/src/element.ts +72 -21
  61. package/src/hash-sync.ts +20 -6
  62. package/src/i18n.ts +15 -14
  63. package/src/index.ts +21 -9
  64. package/src/keyboard.ts +12 -6
  65. package/src/prefs.ts +19 -4
  66. package/src/styles.ts +69 -63
  67. package/src/ui/context.ts +2 -0
  68. package/src/ui/dock.ts +198 -0
  69. package/src/ui/header.ts +38 -168
  70. package/src/ui/popovers.ts +3 -2
  71. package/src/ui/sidebar.ts +2 -1
  72. package/src/ui/stage.ts +49 -114
  73. package/src/ui/swatch.ts +31 -0
package/src/controller.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * Viewer state and orchestration, independent of the UI:
3
3
  *
4
4
  * - load pipeline with progress steps (sources or a precomputed report);
5
- * - mode, selection (with an animated spotlight `emphasis`), family filters, review progress;
6
- * - changes-only focus with expandable collapsed groups (`extendLayout` + scene rebuild);
5
+ * - view (mode × scope), selection (with an animated spotlight `emphasis`), family filters,
6
+ * review progress;
7
+ * - the `changes` scope's focus with expandable collapsed groups (`extendLayout` + scene rebuild);
7
8
  * - replay clock driven by one requestAnimationFrame loop that sleeps when idle, with a camera
8
9
  * that follows the playing hunk / chapter unless the user moved it in the last 2 s;
9
10
  * - swipe position, hover, theme / palette / locale (rebuilding the scene when they change).
@@ -56,10 +57,11 @@ import type {
56
57
  ReviewState,
57
58
  ThemePreference,
58
59
  ViewerMode,
60
+ ViewerScope,
59
61
  } from './index.js';
60
62
  import { buildList, EMPTY_LIST, type ListModel, stepHunk } from './list-model.js';
61
63
  import { type LoadedDiff, type LoadStep, readPayload, runLoad } from './pipeline.js';
62
- import { toLocale, toMode, toPalette, toTheme } from './prefs.js';
64
+ import { LEGACY_CHANGES_MODE, toLocale, toMode, toPalette, toScope, toTheme } from './prefs.js';
63
65
  import { renderBackend } from './renderer-port.js';
64
66
  import { ReplayClock } from './replay.js';
65
67
  import { ReviewStore, reviewKey, type StorageLike } from './review-store.js';
@@ -79,12 +81,13 @@ export type Topic =
79
81
  | 'theme'
80
82
  | 'locale'
81
83
  | 'focus'
82
- | 'swipe';
84
+ | 'swipe'
85
+ | 'overview';
83
86
 
84
87
  export interface ControllerHost {
85
88
  ready(stats: DiffStats): void;
86
89
  select(hunk: string | null, node: string | null): void;
87
- mode(mode: ViewerMode): void;
90
+ mode(mode: ViewerMode, scope: ViewerScope): void;
88
91
  review(hunk: string, reviewed: boolean, progress: ReviewState): void;
89
92
  error(error: Error): void;
90
93
  announce(text: string): void;
@@ -107,7 +110,7 @@ export interface ControllerDeps {
107
110
  readonly storage: StorageLike | null;
108
111
  }
109
112
 
110
- export type Preference = 'mode' | 'theme' | 'palette' | 'locale';
113
+ export type Preference = 'mode' | 'scope' | 'theme' | 'palette' | 'locale';
111
114
 
112
115
  /** Camera follow is suppressed this long after the user moved the camera (ms). */
113
116
  export const USER_CAMERA_GRACE = 2000;
@@ -121,6 +124,8 @@ export const FLY_MAX_ZOOM = 1.8;
121
124
  export const FIT_MAX_ZOOM = 2.5;
122
125
  /** Minimap marks are re-rasterized at most this often while replaying (ms). */
123
126
  const MINIMAP_INTERVAL = 200;
127
+ /** Everything counts as in view (the minimap hides) from this share of the content bounds on. */
128
+ export const OVERVIEW_COVERAGE = 0.98;
124
129
 
125
130
  class Superseded extends Error {}
126
131
 
@@ -169,6 +174,7 @@ export class ViewerController {
169
174
  list: ListModel = EMPTY_LIST;
170
175
 
171
176
  mode: ViewerMode = 'overlay';
177
+ scope: ViewerScope = 'all';
172
178
  themePreference: ThemePreference = 'auto';
173
179
  palette: PaletteName = 'default';
174
180
  locale: Locale = 'en';
@@ -199,6 +205,8 @@ export class ViewerController {
199
205
  /** Canvas clicks select (and double clicks fly); off for embeds without controls. */
200
206
  selectOnClick = true;
201
207
  #labelsInside = false;
208
+ /** The whole content is in view (the minimap has nothing to add). */
209
+ overview = true;
202
210
 
203
211
  #review: ReviewStore | null = null;
204
212
  #searchIndex: SearchIndex | null = null;
@@ -217,6 +225,11 @@ export class ViewerController {
217
225
  #followed = '';
218
226
  #loadToken = 0;
219
227
  #pendingHunk: { readonly id: string; readonly fly: boolean } | null = null;
228
+ #boundsCache: {
229
+ readonly scene: Scene;
230
+ readonly focus: FocusState | null;
231
+ readonly rect: Rect | null;
232
+ } | null = null;
220
233
  #chrome: (() => ChromeLayout) | null = null;
221
234
  /** Camera kept across detach / attach (element moved in the DOM). */
222
235
  #savedCamera: CameraState | null = null;
@@ -402,10 +415,14 @@ export class ViewerController {
402
415
  if (view.locale && !this.explicit.has('locale')) this.locale = view.locale;
403
416
  }
404
417
  const previousMode = this.mode;
405
- if (!this.explicit.has('mode')) {
418
+ const previousScope = this.scope;
419
+ if (!this.explicit.has('mode')) this.mode = view?.mode ?? 'overlay';
420
+ if (layout.scope === 'changes') {
421
+ // Only the changes are laid out: there is no whole graph to show.
422
+ this.scope = 'changes';
423
+ } else if (!this.explicit.has('scope')) {
406
424
  const threshold = loaded.layoutOptions?.largeGraphThreshold ?? 3000;
407
- const large = layout.scope === 'changes' || diff.nodes.length > threshold;
408
- this.mode = view?.mode ?? (large ? 'changes' : 'overlay');
425
+ this.scope = view?.scope ?? (diff.nodes.length > threshold ? 'changes' : 'all');
409
426
  }
410
427
  this.#review = new ReviewStore(
411
428
  this.#deps.storage,
@@ -434,6 +451,7 @@ export class ViewerController {
434
451
  'locale',
435
452
  'focus',
436
453
  'swipe',
454
+ 'overview',
437
455
  );
438
456
  // A new diff starts fitted, after the UI has shown its chrome (the notification above is
439
457
  // delivered first) and once the canvas has a size (see `layoutChanged`).
@@ -441,7 +459,9 @@ export class ViewerController {
441
459
  this.#savedCamera = null;
442
460
  queueMicrotask(() => this.layoutChanged());
443
461
  this.#host.ready(diff.stats);
444
- if (this.mode !== previousMode) this.#host.mode(this.mode);
462
+ if (this.mode !== previousMode || this.scope !== previousScope) {
463
+ this.#host.mode(this.mode, this.scope);
464
+ }
445
465
  const pending = this.#pendingHunk;
446
466
  this.#pendingHunk = null;
447
467
  if (pending !== null) this.selectHunkById(pending.id, { fly: pending.fly });
@@ -482,7 +502,7 @@ export class ViewerController {
482
502
 
483
503
  #updateFocus(): void {
484
504
  const scene = this.scene;
485
- if (!scene || this.mode !== 'changes') {
505
+ if (!scene || this.scope !== 'changes') {
486
506
  this.focus = null;
487
507
  return;
488
508
  }
@@ -513,6 +533,7 @@ export class ViewerController {
513
533
  renderer.on('dblclick', ({ pick }) => this.#onCanvasDblClick(pick)),
514
534
  renderer.on('camera', ({ source }) => {
515
535
  if (source === 'user') this.noteUserCamera();
536
+ this.#updateOverview();
516
537
  }),
517
538
  );
518
539
  this.#minimapDirty = true;
@@ -563,6 +584,7 @@ export class ViewerController {
563
584
  * `autoFit`) fit again unless the user moved the camera since the last fit.
564
585
  */
565
586
  layoutChanged(): void {
587
+ this.#updateOverview();
566
588
  if (this.status !== 'ready') return;
567
589
  if (!this.#needsFit) {
568
590
  if (this.autoFit && !this.#userMoved) void this.fit({ animate: false });
@@ -718,29 +740,74 @@ export class ViewerController {
718
740
  };
719
741
  }
720
742
 
721
- // ─── Mode ─────────────────────────────────────────────────────────────────
743
+ // ─── View: mode × scope ───────────────────────────────────────────────────
744
+
745
+ /** Whether the scope is fixed to `changes` (the layout covers only the changes). */
746
+ get scopeLocked(): boolean {
747
+ return this.layout?.scope === 'changes';
748
+ }
722
749
 
723
- setMode(value: ViewerMode): void {
750
+ /** Switch how the versions are compared. The legacy `changes` means overlay + `changes` scope. */
751
+ setMode(value: ViewerMode | typeof LEGACY_CHANGES_MODE): void {
752
+ if (value === LEGACY_CHANGES_MODE) {
753
+ this.setView('overlay', 'changes');
754
+ return;
755
+ }
724
756
  const mode = toMode(value);
725
- if (mode === null || mode === this.mode) return;
757
+ if (mode !== null) this.setView(mode, this.scope);
758
+ }
759
+
760
+ /** Show the whole graph or only the changes, in any mode. */
761
+ setScope(value: ViewerScope): void {
762
+ const scope = toScope(value);
763
+ if (scope !== null) this.setView(this.mode, scope);
764
+ }
765
+
766
+ /** Toggle the `changes` scope (key `4`); says why when the scope is locked. */
767
+ toggleScope(): void {
768
+ if (this.scopeLocked) {
769
+ this.#host.announce(translate(this.locale, 'scope.locked'));
770
+ return;
771
+ }
772
+ this.setScope(this.scope === 'changes' ? 'all' : 'changes');
773
+ }
774
+
775
+ /** Set mode and scope together (one notification, one `kgdiff-mode` event). */
776
+ setView(mode: ViewerMode, scope: ViewerScope): void {
777
+ const nextScope: ViewerScope = this.scopeLocked ? 'changes' : scope;
778
+ const modeChanged = mode !== this.mode;
779
+ const scopeChanged = nextScope !== this.scope;
780
+ if (!modeChanged && !scopeChanged) return;
726
781
  this.mode = mode;
782
+ this.scope = nextScope;
727
783
  if (mode !== 'replay') {
728
784
  this.clock?.pause();
729
785
  this.#restartAt = Number.NaN;
730
786
  this.#resume = false;
731
787
  }
732
788
  this.#followed = '';
733
- this.#updateFocus();
789
+ if (scopeChanged) {
790
+ this.#updateFocus();
791
+ this.#updateOverview();
792
+ }
734
793
  this.#minimapDirty = true;
735
794
  this.#notify('mode', 'focus', 'replay');
736
795
  this.invalidate();
737
796
  if (this.status === 'ready') {
738
- this.#host.mode(mode);
739
- this.#host.announce(
740
- translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }),
741
- );
797
+ this.#host.mode(mode, nextScope);
798
+ const parts: string[] = [];
799
+ if (modeChanged) {
800
+ parts.push(
801
+ translate(this.locale, 'announce.mode', { mode: translate(this.locale, `mode.${mode}`) }),
802
+ );
803
+ }
804
+ if (scopeChanged) {
805
+ parts.push(translate(this.locale, nextScope === 'changes' ? 'scope.on' : 'scope.off'));
806
+ }
807
+ this.#host.announce(parts.join(' · '));
742
808
  }
743
- if (mode === 'replay') this.#autoplay();
809
+ // Switching to replay starts a looping replay; toggling the scope leaves playback alone.
810
+ if (modeChanged && mode === 'replay') this.#autoplay();
744
811
  }
745
812
 
746
813
  // ─── Selection ────────────────────────────────────────────────────────────
@@ -1059,6 +1126,28 @@ export class ViewerController {
1059
1126
  return { bounds: fit.bounds, insets };
1060
1127
  }
1061
1128
 
1129
+ /** Bounds of what is drawn: the scene, or the visible part of the `changes` scope's focus. */
1130
+ #contentBounds(): Rect | null {
1131
+ const scene = this.scene;
1132
+ if (!scene) return null;
1133
+ const focus = this.focus;
1134
+ const cached = this.#boundsCache;
1135
+ if (cached && cached.scene === scene && cached.focus === focus) return cached.rect;
1136
+ const rect = focus ? focusBounds(scene, focus) : frameBounds(scene);
1137
+ this.#boundsCache = { scene, focus, rect };
1138
+ return rect;
1139
+ }
1140
+
1141
+ /** Whether everything drawn is in view; the minimap hides while it is. */
1142
+ #updateOverview(): void {
1143
+ const bounds = this.#contentBounds();
1144
+ const view = this.#renderer?.camera.viewport();
1145
+ const next = !bounds || !view || coverage(bounds, view) >= OVERVIEW_COVERAGE;
1146
+ if (next === this.overview) return;
1147
+ this.overview = next;
1148
+ this.#notify('overview');
1149
+ }
1150
+
1062
1151
  zoom(direction: 1 | -1): void {
1063
1152
  this.noteUserCamera();
1064
1153
  this.#renderer?.camera.zoomBy(direction > 0 ? 1.4 : 1 / 1.4);
@@ -1164,6 +1253,7 @@ export class ViewerController {
1164
1253
  if (h === this.hops) return;
1165
1254
  this.hops = h;
1166
1255
  this.#updateFocus();
1256
+ this.#updateOverview();
1167
1257
  this.#minimapDirty = true;
1168
1258
  this.#notify('focus');
1169
1259
  this.invalidate();
@@ -1290,6 +1380,7 @@ export class ViewerController {
1290
1380
 
1291
1381
  #focusChanged(): void {
1292
1382
  this.#updateFocus();
1383
+ this.#updateOverview();
1293
1384
  this.#minimapDirty = true;
1294
1385
  this.#notify('focus');
1295
1386
  this.invalidate();
@@ -1550,6 +1641,26 @@ function sameInsets(a: Insets, b: Insets): boolean {
1550
1641
  return a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left;
1551
1642
  }
1552
1643
 
1644
+ /** Bounds of the visible part of `focus`, with room for labels and the collapsed pills. */
1645
+ function focusBounds(scene: Scene, focus: FocusState): Rect {
1646
+ let x0 = Number.POSITIVE_INFINITY;
1647
+ let y0 = Number.POSITIVE_INFINITY;
1648
+ let x1 = Number.NEGATIVE_INFINITY;
1649
+ let y1 = Number.NEGATIVE_INFINITY;
1650
+ const add = (x: number, y: number, rx: number, top: number, bottom: number): void => {
1651
+ x0 = Math.min(x0, x - rx);
1652
+ x1 = Math.max(x1, x + rx);
1653
+ y0 = Math.min(y0, y - top);
1654
+ y1 = Math.max(y1, y + bottom);
1655
+ };
1656
+ // Labels sit centered below their node: leave room for a typical one.
1657
+ for (const n of scene.nodes)
1658
+ if (focus.nodes[n.index] === 1) add(n.x, n.y, n.r + 48, n.r, n.r + 24);
1659
+ for (const g of focus.collapsed) add(g.x, g.y, 48, 12, 12);
1660
+ if (!(x0 <= x1)) return frameBounds(scene);
1661
+ return padRect({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }, 160, 1.12);
1662
+ }
1663
+
1553
1664
  /** Pad a small rect so a node is shown with its neighbourhood. */
1554
1665
  function padRect(r: Rect, min = 200, ratio = 1.4): Rect {
1555
1666
  const w = Math.max(min, r.width * ratio);
package/src/element.ts CHANGED
@@ -18,11 +18,13 @@ import type {
18
18
  ViewerChrome,
19
19
  ViewerLegend,
20
20
  ViewerMode,
21
+ ViewerScope,
21
22
  } from './index.js';
22
23
  import { isTypingTarget, keyAction } from './keyboard.js';
23
24
  import { assertPayload } from './payload.js';
24
25
  import {
25
26
  DEFAULT_LOOP_DELAY,
27
+ LEGACY_CHANGES_MODE,
26
28
  THEME_PREFERENCES,
27
29
  toChrome,
28
30
  toDelay,
@@ -30,6 +32,7 @@ import {
30
32
  toLocale,
31
33
  toMode,
32
34
  toPalette,
35
+ toScope,
33
36
  toTheme,
34
37
  } from './prefs.js';
35
38
  import { defaultStorage } from './review-store.js';
@@ -40,13 +43,14 @@ import { HelpDialog, Menu } from './ui/popovers.js';
40
43
  import { Sidebar } from './ui/sidebar.js';
41
44
  import { Stage } from './ui/stage.js';
42
45
 
43
- /** Below this width the sidebar becomes a bottom sheet and the mode control shows icons. */
46
+ /** Below this width the sidebar becomes a bottom sheet. */
44
47
  export const COMPACT_WIDTH = 760;
45
- /** Below this width the header drops the diffstat pills and version tags. */
48
+ /** Below this width the title drops the version tags. */
46
49
  const NARROW_WIDTH = 1100;
47
50
 
48
51
  const OBSERVED = [
49
52
  'mode',
53
+ 'scope',
50
54
  'theme',
51
55
  'palette',
52
56
  'locale',
@@ -60,6 +64,7 @@ const OBSERVED = [
60
64
  ] as const;
61
65
  const UPGRADED = [
62
66
  'mode',
67
+ 'scope',
63
68
  'theme',
64
69
  'palette',
65
70
  'locale',
@@ -158,8 +163,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
158
163
  this.#emit('kgdiff-select', { hunk, node });
159
164
  this.#writeHash();
160
165
  },
161
- mode: (mode) => {
162
- this.#emit('kgdiff-mode', { mode });
166
+ mode: (mode, scope) => {
167
+ this.#emit('kgdiff-mode', { mode, scope });
163
168
  this.#writeHash();
164
169
  },
165
170
  review: (hunk, reviewed, progress) =>
@@ -223,7 +228,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
223
228
  window.addEventListener('hashchange', onHash);
224
229
  this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
225
230
  this.controller.setChrome(() => ui.stage.chromeLayout());
226
- document.fonts?.ready?.then(() => this.#ui?.header.layout()).catch(() => undefined);
231
+ document.fonts?.ready?.then(() => this.#ui?.stage.layout()).catch(() => undefined);
227
232
  this.#readHash();
228
233
  this.#render(
229
234
  new Set<Topic>([
@@ -238,6 +243,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
238
243
  'focus',
239
244
  'swipe',
240
245
  'replay',
246
+ 'overview',
241
247
  ]),
242
248
  );
243
249
  }
@@ -256,6 +262,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
256
262
  if (this.#reflecting) return;
257
263
  switch (name) {
258
264
  case 'mode':
265
+ case 'scope':
259
266
  case 'theme':
260
267
  case 'palette':
261
268
  case 'locale':
@@ -286,23 +293,39 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
286
293
 
287
294
  /**
288
295
  * Apply a preference from an attribute or a property. Valid values are explicit (a report's
289
- * `view` does not override them); invalid ones fall back to the default (`overlay`, `auto`,
290
- * `default`, the browser's language). Properties reflect the normalized value.
296
+ * `view` does not override them); invalid ones fall back to the default (`overlay`, `all`,
297
+ * `auto`, `default`, the browser's language). Properties reflect the normalized value; the
298
+ * legacy mode `changes` reflects as `overlay` + scope `changes`.
291
299
  */
292
300
  #applyPreference(
293
- name: 'mode' | 'theme' | 'palette' | 'locale',
301
+ name: 'mode' | 'scope' | 'theme' | 'palette' | 'locale',
294
302
  raw: unknown,
295
303
  reflect: boolean,
296
304
  ): void {
297
305
  const c = this.controller;
306
+ // `mode="changes"` is rewritten to the attribute pair it stands for.
307
+ const legacy = name === 'mode' && raw === LEGACY_CHANGES_MODE;
298
308
  let value: string | null;
299
309
  switch (name) {
300
310
  case 'mode': {
311
+ if (legacy) {
312
+ c.explicit.add('scope');
313
+ c.setView('overlay', 'changes');
314
+ value = 'overlay';
315
+ break;
316
+ }
301
317
  const v = toMode(raw);
302
318
  c.setMode(v ?? 'overlay');
303
319
  value = v;
304
320
  break;
305
321
  }
322
+ case 'scope': {
323
+ const v = toScope(raw);
324
+ c.setScope(v ?? 'all');
325
+ // A locked scope stays `changes`: the attribute says what is shown.
326
+ value = v === null ? null : c.scope;
327
+ break;
328
+ }
306
329
  case 'theme': {
307
330
  const v = toTheme(raw);
308
331
  c.setThemePreference(v ?? 'auto');
@@ -326,7 +349,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
326
349
  }
327
350
  if (value === null) c.explicit.delete(name);
328
351
  else c.explicit.add(name);
329
- if (reflect) this.#reflect(name, value);
352
+ if (reflect || legacy) this.#reflect(name, value);
353
+ if (legacy) this.#reflect('scope', c.scope);
330
354
  }
331
355
 
332
356
  // ─── Properties ───────────────────────────────────────────────────────────
@@ -335,10 +359,18 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
335
359
  return this.controller.mode;
336
360
  }
337
361
 
338
- set mode(value: ViewerMode) {
362
+ set mode(value: ViewerMode | typeof LEGACY_CHANGES_MODE) {
339
363
  this.#applyPreference('mode', value, true);
340
364
  }
341
365
 
366
+ get scope(): ViewerScope {
367
+ return this.controller.scope;
368
+ }
369
+
370
+ set scope(value: ViewerScope) {
371
+ this.#applyPreference('scope', value, true);
372
+ }
373
+
342
374
  get theme(): ThemePreference {
343
375
  return this.controller.themePreference;
344
376
  }
@@ -555,6 +587,9 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
555
587
  setMode: (mode) => {
556
588
  this.controller.setMode(mode);
557
589
  },
590
+ toggleScope: () => {
591
+ this.controller.toggleScope();
592
+ },
558
593
  setThemePreference: (pref) => {
559
594
  this.theme = pref;
560
595
  },
@@ -576,12 +611,13 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
576
611
  const menu = new Menu(ctx);
577
612
  const help = new HelpDialog(ctx);
578
613
  const live = h('div', { class: 'sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' });
614
+ // The title and the actions float over the canvas; the menu drops from the actions.
615
+ header.attachMenu(menu.el);
616
+ stage.el.appendChild(header.el);
579
617
  const root = h(
580
618
  'div',
581
619
  { class: 'root' },
582
- header.el,
583
620
  h('div', { class: 'body' }, stage.el, sidebar.el),
584
- menu.el,
585
621
  help.el,
586
622
  live,
587
623
  );
@@ -624,7 +660,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
624
660
  #renderTopics(topics: ReadonlySet<Topic>): void {
625
661
  const ui = this.#ui;
626
662
  const c = this.controller;
627
- if (topics.has('mode')) this.#reflect('mode', c.mode);
663
+ if (topics.has('mode')) {
664
+ this.#reflect('mode', c.mode);
665
+ this.#reflect('scope', c.scope);
666
+ }
628
667
  if (topics.has('theme')) {
629
668
  this.#reflect(
630
669
  'theme',
@@ -645,7 +684,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
645
684
  ui.header.update(topics);
646
685
  ui.sidebar.update(topics);
647
686
  ui.stage.update(topics);
648
- if (topics.has('mode') || topics.has('status')) ui.root.dataset.mode = c.mode;
687
+ if (topics.has('mode') || topics.has('status')) {
688
+ ui.root.dataset.mode = c.mode;
689
+ ui.root.dataset.scope = c.scope;
690
+ }
649
691
  }
650
692
 
651
693
  #toggleSidebar(open?: boolean): void {
@@ -695,7 +737,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
695
737
  this.#compact = compact;
696
738
  ui.root.dataset.compact = String(compact);
697
739
  }
698
- ui.header.layout();
740
+ ui.stage.layout();
699
741
  ui.sidebar.renderWindow();
700
742
  if (compact) ui.sidebar.syncSheet();
701
743
  this.controller.layoutChanged();
@@ -730,7 +772,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
730
772
  const target = path[0] ?? null;
731
773
  if (isTypingTarget(target)) return;
732
774
  const c = this.controller;
733
- const action = keyAction(e, c.mode);
775
+ const action = keyAction(e, c.mode, c.scope);
734
776
  if (!action) return;
735
777
  // Enter / Space on a focused control activate that control.
736
778
  const el = target as Element | null;
@@ -747,6 +789,9 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
747
789
  case 'mode':
748
790
  c.setMode(action.mode);
749
791
  break;
792
+ case 'scope':
793
+ c.toggleScope();
794
+ break;
750
795
  case 'next':
751
796
  c.next();
752
797
  break;
@@ -813,10 +858,12 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
813
858
  const state = parseHash(location.hash);
814
859
  this.#lastHash = location.hash;
815
860
  if (!state) return;
816
- if (state.mode) {
817
- // A deep link outranks the large-graph default and a report's preferred mode.
818
- this.controller.explicit.add('mode');
819
- this.controller.setMode(state.mode);
861
+ if (state.mode || state.scope) {
862
+ // A deep link outranks the large-graph default and a report's preferred view.
863
+ const c = this.controller;
864
+ if (state.mode) c.explicit.add('mode');
865
+ if (state.scope) c.explicit.add('scope');
866
+ c.setView(state.mode ?? c.mode, state.scope ?? c.scope);
820
867
  }
821
868
  if (state.hunk) this.controller.selectHunkById(state.hunk, { fly: true });
822
869
  }
@@ -825,7 +872,11 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
825
872
  if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
826
873
  const c = this.controller;
827
874
  const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
828
- const hash = formatHash({ ...(hunk !== undefined ? { hunk } : {}), mode: c.mode });
875
+ const hash = formatHash({
876
+ ...(hunk !== undefined ? { hunk } : {}),
877
+ mode: c.mode,
878
+ scope: c.scope,
879
+ });
829
880
  if (hash === location.hash) return;
830
881
  this.#lastHash = hash;
831
882
  try {
package/src/hash-sync.ts CHANGED
@@ -1,22 +1,24 @@
1
1
  /**
2
- * Deep links for `sync-hash`: `#kgdiff=hunk:<id>;mode:<mode>`.
2
+ * Deep links for `sync-hash`: `#kgdiff=hunk:<id>;mode:<mode>;scope:<scope>`. The legacy
3
+ * `mode:changes` reads as overlay + the `changes` scope.
3
4
  *
4
5
  * Values are percent-encoded (hunk ids embed union ids, which may be IRIs containing `;`, `#`
5
6
  * or `:`), so parsing splits on the first `:` of each `;`-separated field. Unknown fields are
6
7
  * ignored; a malformed value drops just that field.
7
8
  */
8
- import type { ViewerMode } from './index.js';
9
+ import type { ViewerMode, ViewerScope } from './index.js';
10
+ import { LEGACY_CHANGES_MODE, toMode, toScope } from './prefs.js';
9
11
 
10
12
  export interface HashState {
11
13
  readonly hunk?: string;
12
14
  readonly mode?: ViewerMode;
15
+ readonly scope?: ViewerScope;
13
16
  }
14
17
 
15
18
  const PREFIX = 'kgdiff=';
16
- const MODES: ReadonlySet<string> = new Set<ViewerMode>(['overlay', 'swipe', 'replay', 'changes']);
17
19
 
18
20
  export function isViewerMode(value: unknown): value is ViewerMode {
19
- return typeof value === 'string' && MODES.has(value);
21
+ return toMode(value) !== null;
20
22
  }
21
23
 
22
24
  function decode(value: string): string | undefined {
@@ -33,6 +35,8 @@ export function parseHash(hash: string): HashState | null {
33
35
  if (!raw.startsWith(PREFIX)) return null;
34
36
  let hunk: string | undefined;
35
37
  let mode: ViewerMode | undefined;
38
+ let scope: ViewerScope | undefined;
39
+ let legacy = false;
36
40
  for (const field of raw.slice(PREFIX.length).split(';')) {
37
41
  const colon = field.indexOf(':');
38
42
  if (colon <= 0) continue;
@@ -40,9 +44,18 @@ export function parseHash(hash: string): HashState | null {
40
44
  const value = decode(field.slice(colon + 1));
41
45
  if (value === undefined || value === '') continue;
42
46
  if (key === 'hunk') hunk = value;
43
- else if (key === 'mode' && isViewerMode(value)) mode = value;
47
+ else if (key === 'mode' && value === LEGACY_CHANGES_MODE) {
48
+ mode = 'overlay';
49
+ legacy = true;
50
+ } else if (key === 'mode') mode = toMode(value) ?? mode;
51
+ else if (key === 'scope') scope = toScope(value) ?? scope;
44
52
  }
45
- return { ...(hunk !== undefined ? { hunk } : {}), ...(mode !== undefined ? { mode } : {}) };
53
+ if (legacy) scope ??= 'changes';
54
+ return {
55
+ ...(hunk !== undefined ? { hunk } : {}),
56
+ ...(mode !== undefined ? { mode } : {}),
57
+ ...(scope !== undefined ? { scope } : {}),
58
+ };
46
59
  }
47
60
 
48
61
  /** Encode `state` as a hash (`#kgdiff=…`); empty state gives `#kgdiff=`. */
@@ -50,5 +63,6 @@ export function formatHash(state: HashState): string {
50
63
  const fields: string[] = [];
51
64
  if (state.hunk !== undefined) fields.push(`hunk:${encodeURIComponent(state.hunk)}`);
52
65
  if (state.mode !== undefined) fields.push(`mode:${state.mode}`);
66
+ if (state.scope !== undefined) fields.push(`scope:${state.scope}`);
53
67
  return `#${PREFIX}${fields.join(';')}`;
54
68
  }