xtralab 0.15.1 → 0.15.2

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 (39) hide show
  1. package/README.md +58 -33
  2. package/lib/fileBrowser/commands.d.ts +1 -0
  3. package/lib/fileBrowser/commands.js +46 -1
  4. package/lib/fileBrowser/fileBrowser.js +4 -8
  5. package/lib/fileBrowser/gitStatus.d.ts +16 -5
  6. package/lib/fileBrowser/gitStatus.js +28 -17
  7. package/lib/fileBrowser/widget.d.ts +15 -0
  8. package/lib/fileBrowser/widget.js +13 -0
  9. package/lib/git/commands.d.ts +5 -0
  10. package/lib/git/commands.js +4 -3
  11. package/lib/git/diffPreferences.d.ts +90 -0
  12. package/lib/git/diffPreferences.js +107 -0
  13. package/lib/git/diffProvider.js +10 -2
  14. package/lib/git/diffSurface.d.ts +34 -19
  15. package/lib/git/diffSurface.js +49 -115
  16. package/lib/git/diffTheme.d.ts +7 -0
  17. package/lib/git/diffTheme.js +23 -0
  18. package/lib/git/diffWidget.d.ts +7 -30
  19. package/lib/git/diffWidget.js +56 -100
  20. package/lib/git/imageDiff.d.ts +9 -0
  21. package/lib/git/imageDiff.js +2 -28
  22. package/lib/git/index.js +41 -2
  23. package/lib/git/notebookDiff.js +3 -2
  24. package/package.json +5 -3
  25. package/schema/git-diff-preferences.json +42 -0
  26. package/src/fileBrowser/commands.ts +64 -0
  27. package/src/fileBrowser/fileBrowser.tsx +4 -9
  28. package/src/fileBrowser/gitStatus.ts +33 -20
  29. package/src/fileBrowser/widget.tsx +22 -0
  30. package/src/git/commands.ts +13 -1
  31. package/src/git/diffPreferences.ts +192 -0
  32. package/src/git/diffProvider.tsx +11 -2
  33. package/src/git/diffSurface.tsx +113 -146
  34. package/src/git/diffTheme.ts +24 -0
  35. package/src/git/diffWidget.tsx +108 -138
  36. package/src/git/imageDiff.tsx +12 -34
  37. package/src/git/index.ts +51 -2
  38. package/src/git/notebookDiff.tsx +3 -2
  39. package/style/git.css +4 -2
@@ -5,6 +5,7 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
5
5
  import type { TranslationBundle } from '@jupyterlab/translation';
6
6
  import { undoIcon } from '@jupyterlab/ui-components';
7
7
  import { FileDiff } from '@pierre/diffs/react';
8
+ import { IconDiffSplit, IconDiffUnified, IconWordWrap } from '@pierre/icons';
8
9
  import {
9
10
  diffAcceptRejectHunk,
10
11
  parseDiffFromFile,
@@ -20,16 +21,19 @@ import {
20
21
  NotebookDiffView,
21
22
  type INotebookDiffResult
22
23
  } from './notebookDiff';
23
- import { resolveDiffTheme } from './diffTheme';
24
+ import {
25
+ DEFAULT_SPLIT_RATIO,
26
+ MAX_SPLIT_RATIO,
27
+ MIN_SPLIT_RATIO,
28
+ type DiffStyle,
29
+ type DiffStyleControlMode,
30
+ type ImageDiffViewMode,
31
+ type NotebookDiffViewMode
32
+ } from './diffPreferences';
33
+ import { DIFF_SCROLLBAR_CSS, resolveDiffTheme } from './diffTheme';
24
34
 
25
35
  export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget';
26
36
 
27
- const SPLIT_RATIO_STORAGE_KEY = 'xtralab:diff-split-ratio';
28
-
29
- const MIN_SPLIT_RATIO = 0.1;
30
- const MAX_SPLIT_RATIO = 0.9;
31
- const DEFAULT_SPLIT_RATIO = 0.5;
32
-
33
37
  /**
34
38
  * Injected via the `unsafeCSS` option into the shadow root's `@layer
35
39
  * unsafe`, beating the library's base rule that hardcodes `1fr 1fr`. Reads
@@ -40,95 +44,7 @@ const SPLIT_RESIZE_CSS = `pre[data-diff-type="split"][data-overflow="scroll"] {
40
44
  grid-template-columns: var(--xtralab-split-cols, 1fr 1fr);
41
45
  }`;
42
46
 
43
- function readStoredSplitRatio(): number {
44
- try {
45
- const raw = window.localStorage.getItem(SPLIT_RATIO_STORAGE_KEY);
46
- if (raw === null) {
47
- return DEFAULT_SPLIT_RATIO;
48
- }
49
- const parsed = Number.parseFloat(raw);
50
- if (
51
- Number.isFinite(parsed) &&
52
- parsed >= MIN_SPLIT_RATIO &&
53
- parsed <= MAX_SPLIT_RATIO
54
- ) {
55
- return parsed;
56
- }
57
- } catch {
58
- // localStorage can throw in privacy mode or sandboxed contexts.
59
- }
60
- return DEFAULT_SPLIT_RATIO;
61
- }
62
-
63
- function writeStoredSplitRatio(ratio: number): void {
64
- try {
65
- window.localStorage.setItem(SPLIT_RATIO_STORAGE_KEY, ratio.toString());
66
- } catch {
67
- // See readStoredSplitRatio — best-effort persistence.
68
- }
69
- }
70
-
71
- export type DiffStyle = 'split' | 'unified';
72
-
73
- const DIFF_STYLE_STORAGE_KEY = 'xtralab:diff-style';
74
-
75
- /**
76
- * Read the persisted diff style, defaulting to `'split'`.
77
- */
78
- export function readStoredDiffStyle(): DiffStyle {
79
- try {
80
- const raw = window.localStorage.getItem(DIFF_STYLE_STORAGE_KEY);
81
- if (raw === 'split' || raw === 'unified') {
82
- return raw;
83
- }
84
- } catch {
85
- // See readStoredSplitRatio.
86
- }
87
- return 'split';
88
- }
89
-
90
- /**
91
- * Persist the diff style to local storage (best-effort).
92
- */
93
- export function writeStoredDiffStyle(style: DiffStyle): void {
94
- try {
95
- window.localStorage.setItem(DIFF_STYLE_STORAGE_KEY, style);
96
- } catch {
97
- // Best-effort.
98
- }
99
- }
100
-
101
- export type NotebookDiffViewMode = 'notebook' | 'json';
102
-
103
- const NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY = 'xtralab:notebook-diff-view-mode';
104
-
105
- /**
106
- * Read the persisted notebook view mode, defaulting to `'notebook'`.
107
- */
108
- export function readStoredNotebookViewMode(): NotebookDiffViewMode {
109
- try {
110
- const raw = window.localStorage.getItem(
111
- NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY
112
- );
113
- if (raw === 'json' || raw === 'notebook') {
114
- return raw;
115
- }
116
- } catch {
117
- // See readStoredSplitRatio.
118
- }
119
- return 'notebook';
120
- }
121
-
122
- /**
123
- * Persist the notebook view mode to local storage (best-effort).
124
- */
125
- export function writeStoredNotebookViewMode(mode: NotebookDiffViewMode): void {
126
- try {
127
- window.localStorage.setItem(NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY, mode);
128
- } catch {
129
- // Best-effort.
130
- }
131
- }
47
+ const DIFF_SURFACE_CSS = `${SPLIT_RESIZE_CSS}\n${DIFF_SCROLLBAR_CSS}`;
132
48
 
133
49
  /**
134
50
  * Annotation payload threaded back into `renderAnnotation`; carries the target hunk index.
@@ -238,6 +154,26 @@ interface IDiffSurfaceProps {
238
154
  * Split vs unified layout for the textual file diff (host-controlled).
239
155
  */
240
156
  diffStyle: DiffStyle;
157
+ /**
158
+ * Whether long lines wrap instead of scrolling horizontally (host-controlled).
159
+ */
160
+ lineWrap: boolean;
161
+ /**
162
+ * Left pane width in split view, as a fraction of the diff width (host-controlled).
163
+ */
164
+ splitRatio: number;
165
+ /**
166
+ * Called when the user finishes resizing the split panes.
167
+ */
168
+ onSplitRatioChange: (ratio: number) => void;
169
+ /**
170
+ * 2-up, swipe or onion view for image diffs (host-controlled).
171
+ */
172
+ imageViewMode: ImageDiffViewMode;
173
+ /**
174
+ * Called when the user picks another image view mode.
175
+ */
176
+ onImageViewModeChange: (mode: ImageDiffViewMode) => void;
241
177
  /**
242
178
  * Fires when rendered-notebook availability changes so the host can toggle its Notebook/JSON control.
243
179
  */
@@ -283,6 +219,11 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
283
219
  rendermime,
284
220
  notebookViewMode,
285
221
  diffStyle,
222
+ lineWrap,
223
+ splitRatio,
224
+ onSplitRatioChange,
225
+ imageViewMode,
226
+ onImageViewModeChange,
286
227
  onNotebookAvailabilityChange,
287
228
  onFileDiffActiveChange,
288
229
  onMetadataChange,
@@ -337,9 +278,11 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
337
278
  onFileDiffActiveChange?.(showFileDiff);
338
279
  }, [onFileDiffActiveChange, showFileDiff]);
339
280
 
340
- const [leftRatio, setLeftRatio] = React.useState<number>(() =>
341
- readStoredSplitRatio()
342
- );
281
+ // Local while dragging; the host hears the ratio when a drag ends.
282
+ const [leftRatio, setLeftRatio] = React.useState<number>(splitRatio);
283
+ React.useEffect(() => {
284
+ setLeftRatio(splitRatio);
285
+ }, [splitRatio]);
343
286
  const wrapperRef = React.useRef<HTMLDivElement | null>(null);
344
287
  // Drag listeners read the live ratio from a ref to avoid stale captures.
345
288
  const leftRatioRef = React.useRef(leftRatio);
@@ -461,19 +404,19 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
461
404
  handle.removeEventListener('pointermove', onPointerMove);
462
405
  handle.removeEventListener('pointerup', onPointerEnd);
463
406
  handle.removeEventListener('pointercancel', onPointerEnd);
464
- writeStoredSplitRatio(leftRatioRef.current);
407
+ onSplitRatioChange(leftRatioRef.current);
465
408
  };
466
409
  handle.addEventListener('pointermove', onPointerMove);
467
410
  handle.addEventListener('pointerup', onPointerEnd);
468
411
  handle.addEventListener('pointercancel', onPointerEnd);
469
412
  },
470
- []
413
+ [onSplitRatioChange]
471
414
  );
472
415
 
473
416
  const handleResizerDoubleClick = React.useCallback(() => {
474
417
  setLeftRatio(DEFAULT_SPLIT_RATIO);
475
- writeStoredSplitRatio(DEFAULT_SPLIT_RATIO);
476
- }, []);
418
+ onSplitRatioChange(DEFAULT_SPLIT_RATIO);
419
+ }, [onSplitRatioChange]);
477
420
 
478
421
  const handleResizerKeyDown = React.useCallback(
479
422
  (event: React.KeyboardEvent<HTMLDivElement>) => {
@@ -502,9 +445,9 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
502
445
  Math.min(MAX_SPLIT_RATIO, next)
503
446
  );
504
447
  setLeftRatio(clamped);
505
- writeStoredSplitRatio(clamped);
448
+ onSplitRatioChange(clamped);
506
449
  },
507
- []
450
+ [onSplitRatioChange]
508
451
  );
509
452
 
510
453
  if (loading) {
@@ -535,6 +478,8 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
535
478
  reference={oldText}
536
479
  challenger={newText}
537
480
  fileType={imageType}
481
+ mode={imageViewMode}
482
+ onModeChange={onImageViewModeChange}
538
483
  trans={trans}
539
484
  />
540
485
  </div>
@@ -549,6 +494,9 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
549
494
  }
550
495
 
551
496
  const leftPercent = leftRatio * 100;
497
+ // The wrapped split grid sizes its gutter tracks to their content, so a
498
+ // percentage split cannot line up with the handle.
499
+ const showResizer = showFileDiff && diffStyle === 'split' && !lineWrap;
552
500
  const hostStyle = {
553
501
  '--xtralab-split-cols': `${leftPercent}% ${100 - leftPercent}%`
554
502
  } as React.CSSProperties;
@@ -576,11 +524,12 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
576
524
  disableWorkerPool={true}
577
525
  options={{
578
526
  diffStyle,
527
+ overflow: lineWrap ? 'wrap' : 'scroll',
579
528
  disableFileHeader: true,
580
529
  theme: resolveDiffTheme(dark, pierreTheme),
581
530
  themeType: dark ? 'dark' : 'light',
582
531
  // Constant string lets the library skip its unsafeCSS re-render path.
583
- unsafeCSS: SPLIT_RESIZE_CSS,
532
+ unsafeCSS: DIFF_SURFACE_CSS,
584
533
  ...(onLineAsk !== undefined
585
534
  ? {
586
535
  enableLineSelection: true,
@@ -592,7 +541,7 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
592
541
  />
593
542
  ) : null}
594
543
  </div>
595
- {showFileDiff && diffStyle === 'split' ? (
544
+ {showResizer ? (
596
545
  <div
597
546
  className="jp-xtralab-DiffWidget-resizer"
598
547
  style={{ left: `${leftPercent}%` }}
@@ -661,54 +610,41 @@ export function NotebookViewModeControl(props: {
661
610
  }
662
611
 
663
612
  /**
664
- * Split / unified glyphs inlined from the `@pierre/diffs` icon sprite so
665
- * the toggle matches the library's docs site without the sprite sheet;
666
- * `currentColor` lets the button styling drive the fill.
667
- */
668
- function DiffSplitIcon(): React.ReactElement {
669
- return (
670
- <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
671
- <path d="M14 0H8.5v16H14a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2m-1.5 6.5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0" />
672
- <path
673
- d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h5.5V0zm.5 7.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1 0-1"
674
- opacity=".3"
675
- />
676
- </svg>
677
- );
678
- }
679
-
680
- function DiffUnifiedIcon(): React.ReactElement {
681
- return (
682
- <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
683
- <path
684
- fillRule="evenodd"
685
- d="M16 14a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V8.5h16zm-8-4a.5.5 0 0 0-.5.5v1h-1a.5.5 0 0 0 0 1h1v1a.5.5 0 0 0 1 0v-1h1a.5.5 0 0 0 0-1h-1v-1A.5.5 0 0 0 8 10"
686
- clipRule="evenodd"
687
- />
688
- <path
689
- fillRule="evenodd"
690
- d="M14 0a2 2 0 0 1 2 2v5.5H0V2a2 2 0 0 1 2-2zM6.5 3.5a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1z"
691
- clipRule="evenodd"
692
- opacity=".4"
693
- />
694
- </svg>
695
- );
696
- }
697
-
698
- /**
699
- * Segmented Split/Unified selector; mirrors {@link NotebookViewModeControl}.
613
+ * Split/Unified selector: two segmented buttons, or with `control: 'toggle'`
614
+ * one button that shows the layout it switches to.
700
615
  * `available` mirrors the surface's `onFileDiffActiveChange`.
701
616
  */
702
617
  export function DiffStyleControl(props: {
703
618
  diffStyle: DiffStyle;
619
+ control: DiffStyleControlMode;
704
620
  available: boolean;
705
621
  onChange: (style: DiffStyle) => void;
706
622
  trans: TranslationBundle;
707
623
  }): React.ReactElement {
708
- const { diffStyle, available, onChange, trans } = props;
624
+ const { diffStyle, control, available, onChange, trans } = props;
709
625
  if (!available) {
710
626
  return <></>;
711
627
  }
628
+ if (control === 'toggle') {
629
+ const next: DiffStyle = diffStyle === 'split' ? 'unified' : 'split';
630
+ const label =
631
+ next === 'split'
632
+ ? trans.__('Switch to split view')
633
+ : trans.__('Switch to unified view');
634
+ return (
635
+ <div className="jp-xtralab-DiffWidget-segmented">
636
+ <button
637
+ type="button"
638
+ className="jp-xtralab-DiffWidget-segmentedButton jp-xtralab-DiffWidget-segmentedButton-icon"
639
+ title={label}
640
+ aria-label={label}
641
+ onClick={() => onChange(next)}
642
+ >
643
+ {next === 'split' ? <IconDiffSplit /> : <IconDiffUnified />}
644
+ </button>
645
+ </div>
646
+ );
647
+ }
712
648
  return (
713
649
  <div
714
650
  className="jp-xtralab-DiffWidget-segmented"
@@ -725,7 +661,7 @@ export function DiffStyleControl(props: {
725
661
  aria-label={trans.__('Split view')}
726
662
  onClick={() => onChange('split')}
727
663
  >
728
- <DiffSplitIcon />
664
+ <IconDiffSplit />
729
665
  </button>
730
666
  <button
731
667
  type="button"
@@ -737,7 +673,38 @@ export function DiffStyleControl(props: {
737
673
  aria-label={trans.__('Unified view')}
738
674
  onClick={() => onChange('unified')}
739
675
  >
740
- <DiffUnifiedIcon />
676
+ <IconDiffUnified />
677
+ </button>
678
+ </div>
679
+ );
680
+ }
681
+
682
+ /**
683
+ * Line-wrap toggle styled as a one-button segmented control.
684
+ * `available` mirrors the surface's `onFileDiffActiveChange`.
685
+ */
686
+ export function LineWrapControl(props: {
687
+ lineWrap: boolean;
688
+ available: boolean;
689
+ onChange: (wrap: boolean) => void;
690
+ trans: TranslationBundle;
691
+ }): React.ReactElement {
692
+ const { lineWrap, available, onChange, trans } = props;
693
+ if (!available) {
694
+ return <></>;
695
+ }
696
+ return (
697
+ <div className="jp-xtralab-DiffWidget-segmented">
698
+ <button
699
+ type="button"
700
+ aria-pressed={lineWrap}
701
+ data-active={lineWrap}
702
+ className="jp-xtralab-DiffWidget-segmentedButton jp-xtralab-DiffWidget-segmentedButton-icon"
703
+ title={trans.__('Wrap long lines')}
704
+ aria-label={trans.__('Wrap lines')}
705
+ onClick={() => onChange(!lineWrap)}
706
+ >
707
+ <IconWordWrap />
741
708
  </button>
742
709
  </div>
743
710
  );
@@ -26,6 +26,30 @@ registerCustomCSSVariableTheme(JUPYTERLAB_DIFF_THEME, {
26
26
  'token-link': 'var(--jp-mirror-editor-link-color)'
27
27
  });
28
28
 
29
+ /**
30
+ * `unsafeCSS` for a taller, always-visible horizontal scrollbar. The library
31
+ * default is 6px and only paints the thumb on hover, in a color close to the
32
+ * background, so users miss that long lines scroll. Same thumb color as the
33
+ * app scrollbars in chrome.css.
34
+ */
35
+ export const DIFF_SCROLLBAR_CSS = `:host {
36
+ --diffs-scrollbar-gutter-override: 10px;
37
+ --xtralab-diff-scrollbar-thumb: color-mix(in srgb, var(--jp-ui-font-color1) 28%, transparent);
38
+ }
39
+ [data-code]::-webkit-scrollbar-thumb {
40
+ background-color: var(--xtralab-diff-scrollbar-thumb);
41
+ border-width: 2px;
42
+ border-radius: 999px;
43
+ }
44
+ [data-code]::-webkit-scrollbar-thumb:hover {
45
+ background-color: color-mix(in srgb, var(--jp-ui-font-color1) 45%, transparent);
46
+ }
47
+ @supports (-moz-appearance: none) {
48
+ [data-code] {
49
+ scrollbar-color: var(--xtralab-diff-scrollbar-thumb) transparent;
50
+ }
51
+ }`;
52
+
29
53
  /**
30
54
  * Choose Pierre's palette or the JupyterLab CSS-variable theme.
31
55
  */