xtralab 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtralab",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "An opinionated JupyterLab meta-package that bundles a curated set of extensions, ships a path-first file browser, renders jupyterlab-git's text, notebook and image diffs with its own viewers, and applies a quieter default workspace configuration.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -15,11 +15,15 @@ import { ISignal, Signal } from '@lumino/signaling';
15
15
 
16
16
  import {
17
17
  DIFF_WIDGET_CSS_CLASS,
18
+ DiffStyleControl,
18
19
  DiffSurface,
19
20
  NotebookViewModeControl,
20
21
  isDarkTheme,
22
+ readStoredDiffStyle,
21
23
  readStoredNotebookViewMode,
24
+ writeStoredDiffStyle,
22
25
  writeStoredNotebookViewMode,
26
+ type DiffStyle,
23
27
  type NotebookDiffViewMode
24
28
  } from './diffSurface';
25
29
  import { IMAGE_DIFF_EXTENSIONS, imageDataType } from './imageDiff';
@@ -60,6 +64,7 @@ class XtralabDiffWidget extends ReactWidget implements Git.Diff.IDiffWidget {
60
64
  this._model = model;
61
65
  this._context = context;
62
66
  this._notebookViewMode = readStoredNotebookViewMode();
67
+ this._diffStyle = readStoredDiffStyle();
63
68
  // `jp-git-diff-root` borrows jupyterlab-git's host sizing (height:100%
64
69
  // flex column inside its PreviewMainAreaWidget); `jp-xtralab-DiffWidget`
65
70
  // brings our diff theming + the `.jp-xtralab-DiffWidget-*` styles.
@@ -146,6 +151,45 @@ class XtralabDiffWidget extends ReactWidget implements Git.Diff.IDiffWidget {
146
151
  return this._hasNotebookViewChanged;
147
152
  }
148
153
 
154
+ /** The user's current split-vs-unified layout choice for file diffs. */
155
+ get diffStyle(): DiffStyle {
156
+ return this._diffStyle;
157
+ }
158
+
159
+ setDiffStyle(style: DiffStyle): void {
160
+ if (style === this._diffStyle) {
161
+ return;
162
+ }
163
+ this._diffStyle = style;
164
+ writeStoredDiffStyle(style);
165
+ this._diffStyleChanged.emit(style);
166
+ }
167
+
168
+ get diffStyleChanged(): ISignal<this, DiffStyle> {
169
+ return this._diffStyleChanged;
170
+ }
171
+
172
+ /**
173
+ * Whether the textual/code file diff (the only view the split-vs-unified
174
+ * choice affects) is currently active; mirrored into the toolbar so the
175
+ * Split/Unified selector only shows when it applies.
176
+ */
177
+ get fileDiffActive(): boolean {
178
+ return this._fileDiffActive;
179
+ }
180
+
181
+ setFileDiffActive(value: boolean): void {
182
+ if (value === this._fileDiffActive) {
183
+ return;
184
+ }
185
+ this._fileDiffActive = value;
186
+ this._fileDiffActiveChanged.emit(value);
187
+ }
188
+
189
+ get fileDiffActiveChanged(): ISignal<this, boolean> {
190
+ return this._fileDiffActiveChanged;
191
+ }
192
+
149
193
  /** Resolve the in-flight {@link refresh} promise, if any. */
150
194
  settleRefresh(): void {
151
195
  const pending = this._pendingRefresh;
@@ -175,6 +219,10 @@ class XtralabDiffWidget extends ReactWidget implements Git.Diff.IDiffWidget {
175
219
  );
176
220
  private _hasNotebookView = false;
177
221
  private _hasNotebookViewChanged = new Signal<this, boolean>(this);
222
+ private _diffStyle: DiffStyle;
223
+ private _diffStyleChanged = new Signal<this, DiffStyle>(this);
224
+ private _fileDiffActive = false;
225
+ private _fileDiffActiveChanged = new Signal<this, boolean>(this);
178
226
  }
179
227
 
180
228
  interface IModelDiffState {
@@ -225,6 +273,29 @@ function ModelDiffView(props: {
225
273
  [widget]
226
274
  );
227
275
 
276
+ // Mirror the widget's diff-style choice so toolbar-driven changes
277
+ // re-render the diff. The widget owns the canonical value.
278
+ const [diffStyle, setDiffStyle] = React.useState<DiffStyle>(
279
+ () => widget.diffStyle
280
+ );
281
+ React.useEffect(() => {
282
+ const handler = (_sender: XtralabDiffWidget, style: DiffStyle): void => {
283
+ setDiffStyle(style);
284
+ };
285
+ widget.diffStyleChanged.connect(handler);
286
+ setDiffStyle(widget.diffStyle);
287
+ return () => {
288
+ widget.diffStyleChanged.disconnect(handler);
289
+ };
290
+ }, [widget]);
291
+
292
+ const handleFileDiffActiveChange = React.useCallback(
293
+ (active: boolean) => {
294
+ widget.setFileDiffActive(active);
295
+ },
296
+ [widget]
297
+ );
298
+
228
299
  const [dark, setDark] = React.useState<boolean>(() =>
229
300
  isDarkTheme(themeManager)
230
301
  );
@@ -337,7 +408,9 @@ function ModelDiffView(props: {
337
408
  dark={dark}
338
409
  rendermime={rendermime}
339
410
  notebookViewMode={notebookViewMode}
411
+ diffStyle={diffStyle}
340
412
  onNotebookAvailabilityChange={handleNotebookAvailabilityChange}
413
+ onFileDiffActiveChange={handleFileDiffActiveChange}
341
414
  hunkDiscard={hunkDiscard}
342
415
  />
343
416
  );
@@ -401,6 +474,60 @@ function ToolbarControl(props: {
401
474
  );
402
475
  }
403
476
 
477
+ /**
478
+ * Split/Unified toggle mounted into the diff toolbar jupyterlab-git passes
479
+ * to the factory. Bound to the widget's signals so it stays in sync with the
480
+ * rendered surface and only appears while the textual/code file diff is the
481
+ * active view.
482
+ */
483
+ class DiffStyleToolbarItem extends ReactWidget {
484
+ constructor(widget: XtralabDiffWidget) {
485
+ super();
486
+ this._widget = widget;
487
+ this.addClass('jp-xtralab-DiffWidget-diffStyleToolbarItem');
488
+ }
489
+
490
+ protected render(): React.ReactElement {
491
+ return <DiffStyleControlBound widget={this._widget} />;
492
+ }
493
+
494
+ private _widget: XtralabDiffWidget;
495
+ }
496
+
497
+ function DiffStyleControlBound(props: {
498
+ widget: XtralabDiffWidget;
499
+ }): React.ReactElement {
500
+ const { widget } = props;
501
+ const [style, setStyle] = React.useState<DiffStyle>(() => widget.diffStyle);
502
+ const [available, setAvailable] = React.useState<boolean>(
503
+ () => widget.fileDiffActive
504
+ );
505
+ React.useEffect(() => {
506
+ const onStyle = (_sender: XtralabDiffWidget, next: DiffStyle): void => {
507
+ setStyle(next);
508
+ };
509
+ const onAvailable = (_sender: XtralabDiffWidget, next: boolean): void => {
510
+ setAvailable(next);
511
+ };
512
+ widget.diffStyleChanged.connect(onStyle);
513
+ widget.fileDiffActiveChanged.connect(onAvailable);
514
+ setStyle(widget.diffStyle);
515
+ setAvailable(widget.fileDiffActive);
516
+ return () => {
517
+ widget.diffStyleChanged.disconnect(onStyle);
518
+ widget.fileDiffActiveChanged.disconnect(onAvailable);
519
+ };
520
+ }, [widget]);
521
+
522
+ return (
523
+ <DiffStyleControl
524
+ diffStyle={style}
525
+ available={available}
526
+ onChange={next => widget.setDiffStyle(next)}
527
+ />
528
+ );
529
+ }
530
+
404
531
  /**
405
532
  * Build the `Git.Diff.Factory` xtralab registers with jupyterlab-git. The
406
533
  * factory closes over the application-level context (contents manager for
@@ -417,6 +544,10 @@ function makeXtralabDiffFactory(
417
544
  'xtralab-notebook-view-mode',
418
545
  new NotebookViewModeToolbarItem(widget)
419
546
  );
547
+ options.toolbar.addItem(
548
+ 'xtralab-diff-style',
549
+ new DiffStyleToolbarItem(widget)
550
+ );
420
551
  }
421
552
  return widget;
422
553
  };
@@ -101,6 +101,35 @@ function writeStoredSplitRatio(ratio: number): void {
101
101
  }
102
102
  }
103
103
 
104
+ /**
105
+ * Layout for textual/code diffs, forwarded to `@pierre/diffs` as its
106
+ * `diffStyle` option. `split` is the side-by-side old|new view; `unified`
107
+ * is the single-column inline view (what diffs.com calls "stacked").
108
+ */
109
+ export type DiffStyle = 'split' | 'unified';
110
+
111
+ const DIFF_STYLE_STORAGE_KEY = 'xtralab:diff-style';
112
+
113
+ export function readStoredDiffStyle(): DiffStyle {
114
+ try {
115
+ const raw = window.localStorage.getItem(DIFF_STYLE_STORAGE_KEY);
116
+ if (raw === 'split' || raw === 'unified') {
117
+ return raw;
118
+ }
119
+ } catch {
120
+ // See readStoredSplitRatio — privacy-mode / sandboxed contexts can throw.
121
+ }
122
+ return 'split';
123
+ }
124
+
125
+ export function writeStoredDiffStyle(style: DiffStyle): void {
126
+ try {
127
+ window.localStorage.setItem(DIFF_STYLE_STORAGE_KEY, style);
128
+ } catch {
129
+ // Best-effort.
130
+ }
131
+ }
132
+
104
133
  /**
105
134
  * View modes available for `.ipynb` diffs. `notebook` is the cell-by-cell
106
135
  * rendered view; `json` is the raw nbformat JSON file diff, for inspecting
@@ -212,11 +241,20 @@ export interface IDiffSurfaceProps {
212
241
  rendermime: IRenderMimeRegistry | null;
213
242
  /** Current rendered-vs-JSON choice for notebook diffs (host-controlled). */
214
243
  notebookViewMode: NotebookDiffViewMode;
244
+ /** Split vs unified layout for the textual/code file diff (host-controlled). */
245
+ diffStyle: DiffStyle;
215
246
  /**
216
247
  * Called whenever the availability of a rendered notebook view changes,
217
248
  * so the host can show/hide its Notebook/JSON toolbar toggle.
218
249
  */
219
250
  onNotebookAvailabilityChange?: (available: boolean) => void;
251
+ /**
252
+ * Called whenever the textual/code file diff (the only view the split vs
253
+ * unified choice affects) becomes the active view, so the host can
254
+ * show/hide its Split/Unified toolbar toggle. False for image, binary,
255
+ * rendered-notebook, loading and error states.
256
+ */
257
+ onFileDiffActiveChange?: (active: boolean) => void;
220
258
  /**
221
259
  * Called after each (re)computation of the diff with the number of hunks
222
260
  * (or `null` when no textual diff exists). Hosts use this to implement
@@ -243,7 +281,9 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
243
281
  dark,
244
282
  rendermime,
245
283
  notebookViewMode,
284
+ diffStyle,
246
285
  onNotebookAvailabilityChange,
286
+ onFileDiffActiveChange,
247
287
  onMetadataChange,
248
288
  hunkDiscard
249
289
  } = props;
@@ -295,6 +335,21 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
295
335
  });
296
336
  }, [onMetadataChange, metadata]);
297
337
 
338
+ // Decide which view to render. The notebook view requires a successful
339
+ // nbformat parse; if that failed, we only show the raw JSON file diff.
340
+ // Computed before the early returns below so the file-diff-active effect
341
+ // can depend on it without violating the rules of hooks.
342
+ const hasNotebookView = notebookDiff !== null;
343
+ const showNotebookView = hasNotebookView && notebookViewMode === 'notebook';
344
+ const showFileDiff = !showNotebookView && metadata !== null;
345
+
346
+ // The split vs unified choice only affects the textual/code file diff, so
347
+ // tell the host whether that view is active to drive its Split/Unified
348
+ // toolbar toggle.
349
+ React.useEffect(() => {
350
+ onFileDiffActiveChange?.(showFileDiff);
351
+ }, [onFileDiffActiveChange, showFileDiff]);
352
+
298
353
  // Split ratio for the diff columns: fraction of width given to the
299
354
  // deletions (left) pane. Persisted across sessions so the user only
300
355
  // dials in their layout once.
@@ -461,12 +516,6 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
461
516
  </div>
462
517
  );
463
518
  }
464
- // Decide which view to render. The notebook view requires a successful
465
- // nbformat parse; if that failed, we only show the raw JSON file diff.
466
- const hasNotebookView = notebookDiff !== null;
467
- const showNotebookView = hasNotebookView && notebookViewMode === 'notebook';
468
- const showFileDiff = !showNotebookView && metadata !== null;
469
-
470
519
  if (!showNotebookView && !showFileDiff) {
471
520
  return (
472
521
  <div className="jp-xtralab-DiffWidget-status">No content to diff.</div>
@@ -506,7 +555,7 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
506
555
  // fine for the small files git diffs typically operate on.
507
556
  disableWorkerPool={true}
508
557
  options={{
509
- diffStyle: 'split',
558
+ diffStyle,
510
559
  // Drop the file header — the tab title already shows the
511
560
  // file name and the panel header carries the change
512
561
  // context.
@@ -523,7 +572,7 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
523
572
  />
524
573
  ) : null}
525
574
  </div>
526
- {showFileDiff ? (
575
+ {showFileDiff && diffStyle === 'split' ? (
527
576
  <div
528
577
  className="jp-xtralab-DiffWidget-resizer"
529
578
  style={{ left: `${leftPercent}%` }}
@@ -586,3 +635,90 @@ export function NotebookViewModeControl(props: {
586
635
  </div>
587
636
  );
588
637
  }
638
+
639
+ /**
640
+ * Split / unified glyphs, inlined from the `@pierre/diffs` icon sprite
641
+ * (`diffs-icon-diff-split` / `diffs-icon-diff-unified`) so the toggle looks
642
+ * like the one on the library's own docs site without depending on the
643
+ * sprite sheet being injected into the document. `currentColor` lets the
644
+ * segmented-button styling drive the fill (including the active state).
645
+ */
646
+ function DiffSplitIcon(): React.ReactElement {
647
+ return (
648
+ <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
649
+ <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" />
650
+ <path
651
+ 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"
652
+ opacity=".3"
653
+ />
654
+ </svg>
655
+ );
656
+ }
657
+
658
+ function DiffUnifiedIcon(): React.ReactElement {
659
+ return (
660
+ <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
661
+ <path
662
+ fillRule="evenodd"
663
+ 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"
664
+ clipRule="evenodd"
665
+ />
666
+ <path
667
+ fillRule="evenodd"
668
+ 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"
669
+ clipRule="evenodd"
670
+ opacity=".4"
671
+ />
672
+ </svg>
673
+ );
674
+ }
675
+
676
+ /**
677
+ * Segmented Split/Unified selector. Mirrors {@link NotebookViewModeControl}:
678
+ * hosts mount it into their own toolbar and drive its value/visibility from
679
+ * the same state they pass to {@link DiffSurface}. Only meaningful while the
680
+ * textual/code file diff is the active view, so `available` mirrors the
681
+ * surface's `onFileDiffActiveChange`.
682
+ */
683
+ export function DiffStyleControl(props: {
684
+ diffStyle: DiffStyle;
685
+ available: boolean;
686
+ onChange: (style: DiffStyle) => void;
687
+ }): React.ReactElement {
688
+ const { diffStyle, available, onChange } = props;
689
+ if (!available) {
690
+ return <></>;
691
+ }
692
+ return (
693
+ <div
694
+ className="jp-xtralab-DiffWidget-segmented"
695
+ role="tablist"
696
+ aria-label="Diff view style"
697
+ >
698
+ <button
699
+ type="button"
700
+ role="tab"
701
+ aria-selected={diffStyle === 'split'}
702
+ data-active={diffStyle === 'split'}
703
+ className="jp-xtralab-DiffWidget-segmentedButton jp-xtralab-DiffWidget-segmentedButton-icon"
704
+ title="Split (side-by-side) view"
705
+ aria-label="Split view"
706
+ onClick={() => onChange('split')}
707
+ >
708
+ <DiffSplitIcon />
709
+ </button>
710
+ <button
711
+ type="button"
712
+ role="tab"
713
+ aria-selected={diffStyle === 'unified'}
714
+ data-active={diffStyle === 'unified'}
715
+ className="jp-xtralab-DiffWidget-segmentedButton jp-xtralab-DiffWidget-segmentedButton-icon"
716
+ title="Unified (inline) view"
717
+ aria-label="Unified view"
718
+ onClick={() => onChange('unified')}
719
+ >
720
+ <DiffUnifiedIcon />
721
+ </button>
722
+ </div>
723
+ );
724
+ }
@@ -16,11 +16,15 @@ import { content } from './api';
16
16
  import { imageDataType } from './imageDiff';
17
17
  import {
18
18
  DIFF_WIDGET_CSS_CLASS,
19
+ DiffStyleControl,
19
20
  DiffSurface,
20
21
  NotebookViewModeControl,
21
22
  isDarkTheme,
23
+ readStoredDiffStyle,
22
24
  readStoredNotebookViewMode,
25
+ writeStoredDiffStyle,
23
26
  writeStoredNotebookViewMode,
27
+ type DiffStyle,
24
28
  type NotebookDiffViewMode
25
29
  } from './diffSurface';
26
30
  import { GitReference, IFileChange } from './tokens';
@@ -158,17 +162,18 @@ export function createGitDiffWidget(
158
162
  'notebook-view-mode',
159
163
  new NotebookViewModeToolbarItem(content)
160
164
  );
165
+ widget.toolbar.addItem('diff-style', new DiffStyleToolbarItem(content));
161
166
  const syncToolbarVisibility = (): void => {
162
167
  if (widget === null || widget.isDisposed) {
163
168
  return;
164
169
  }
165
- if (!content.pinned || content.hasNotebookView) {
170
+ if (!content.pinned || content.hasNotebookView || content.fileDiffActive) {
166
171
  widget.toolbar.show();
167
172
  } else {
168
173
  widget.toolbar.hide();
169
174
  }
170
175
  };
171
- const onHasNotebookViewChanged = (): void => {
176
+ const onToolbarStateChanged = (): void => {
172
177
  if (widget === null || widget.isDisposed) {
173
178
  return;
174
179
  }
@@ -190,11 +195,13 @@ export function createGitDiffWidget(
190
195
  }
191
196
  syncToolbarVisibility();
192
197
  };
193
- content.hasNotebookViewChanged.connect(onHasNotebookViewChanged);
198
+ content.hasNotebookViewChanged.connect(onToolbarStateChanged);
199
+ content.fileDiffActiveChanged.connect(onToolbarStateChanged);
194
200
  content.pinnedChanged.connect(onPinnedChanged);
195
201
  syncToolbarVisibility();
196
202
  widget.disposed.connect(() => {
197
- content.hasNotebookViewChanged.disconnect(onHasNotebookViewChanged);
203
+ content.hasNotebookViewChanged.disconnect(onToolbarStateChanged);
204
+ content.fileDiffActiveChanged.disconnect(onToolbarStateChanged);
198
205
  content.pinnedChanged.disconnect(onPinnedChanged);
199
206
  });
200
207
  return widget;
@@ -228,6 +235,7 @@ export class DiffContentWidget extends ReactWidget {
228
235
  this._options = options;
229
236
  this._pinned = options.pinned === true;
230
237
  this._notebookViewMode = readStoredNotebookViewMode();
238
+ this._diffStyle = readStoredDiffStyle();
231
239
  }
232
240
 
233
241
  /** The change this widget renders, used by callers to identify the widget. */
@@ -277,6 +285,48 @@ export class DiffContentWidget extends ReactWidget {
277
285
  return this._hasNotebookViewChanged;
278
286
  }
279
287
 
288
+ /** The user's current split-vs-unified layout choice for file diffs. */
289
+ get diffStyle(): DiffStyle {
290
+ return this._diffStyle;
291
+ }
292
+
293
+ /** Update the diff layout and persist it across sessions. */
294
+ setDiffStyle(style: DiffStyle): void {
295
+ if (style === this._diffStyle) {
296
+ return;
297
+ }
298
+ this._diffStyle = style;
299
+ writeStoredDiffStyle(style);
300
+ this._diffStyleChanged.emit(style);
301
+ }
302
+
303
+ /** Emits whenever {@link diffStyle} changes. */
304
+ get diffStyleChanged(): ISignal<this, DiffStyle> {
305
+ return this._diffStyleChanged;
306
+ }
307
+
308
+ /**
309
+ * Whether the textual/code file diff (the only view the split-vs-unified
310
+ * choice affects) is currently active. Set by the React content; the
311
+ * toolbar reads it to decide whether to show the Split/Unified selector.
312
+ */
313
+ get fileDiffActive(): boolean {
314
+ return this._fileDiffActive;
315
+ }
316
+
317
+ setFileDiffActive(value: boolean): void {
318
+ if (value === this._fileDiffActive) {
319
+ return;
320
+ }
321
+ this._fileDiffActive = value;
322
+ this._fileDiffActiveChanged.emit(value);
323
+ }
324
+
325
+ /** Emits whenever {@link fileDiffActive} changes. */
326
+ get fileDiffActiveChanged(): ISignal<this, boolean> {
327
+ return this._fileDiffActiveChanged;
328
+ }
329
+
280
330
  get pinned(): boolean {
281
331
  return this._pinned;
282
332
  }
@@ -309,6 +359,10 @@ export class DiffContentWidget extends ReactWidget {
309
359
  );
310
360
  private _hasNotebookView = false;
311
361
  private _hasNotebookViewChanged = new Signal<this, boolean>(this);
362
+ private _diffStyle: DiffStyle;
363
+ private _diffStyleChanged = new Signal<this, DiffStyle>(this);
364
+ private _fileDiffActive = false;
365
+ private _fileDiffActiveChanged = new Signal<this, boolean>(this);
312
366
  private _pinned: boolean;
313
367
  private _pinnedChanged = new Signal<this, boolean>(this);
314
368
  }
@@ -379,6 +433,32 @@ function DiffViewer(
379
433
  };
380
434
  }, [widget]);
381
435
 
436
+ // Mirror the widget's diff-style choice the same way (the widget owns the
437
+ // canonical value so the toolbar can read and write it without going
438
+ // through React).
439
+ const [diffStyle, setDiffStyleLocal] = React.useState<DiffStyle>(
440
+ () => widget.diffStyle
441
+ );
442
+ React.useEffect(() => {
443
+ const handler = (_sender: DiffContentWidget, style: DiffStyle): void => {
444
+ setDiffStyleLocal(style);
445
+ };
446
+ widget.diffStyleChanged.connect(handler);
447
+ setDiffStyleLocal(widget.diffStyle);
448
+ return () => {
449
+ widget.diffStyleChanged.disconnect(handler);
450
+ };
451
+ }, [widget]);
452
+
453
+ // Tell the host widget whether the textual/code file diff is the active
454
+ // view, so the toolbar's Split/Unified selector only shows when it applies.
455
+ const handleFileDiffActiveChange = React.useCallback(
456
+ (active: boolean) => {
457
+ widget.setFileDiffActive(active);
458
+ },
459
+ [widget]
460
+ );
461
+
382
462
  // Tell the host widget whether a rendered notebook view is currently
383
463
  // available. The toolbar item that lives in the `MainAreaWidget`
384
464
  // toolbar reads this flag to decide whether to show the
@@ -558,7 +638,9 @@ function DiffViewer(
558
638
  dark={dark}
559
639
  rendermime={rendermime}
560
640
  notebookViewMode={notebookViewMode}
641
+ diffStyle={diffStyle}
561
642
  onNotebookAvailabilityChange={handleNotebookAvailabilityChange}
643
+ onFileDiffActiveChange={handleFileDiffActiveChange}
562
644
  onMetadataChange={handleMetadataChange}
563
645
  hunkDiscard={hunkDiscard}
564
646
  />
@@ -627,6 +709,61 @@ function NotebookViewModeToolbarControl(props: {
627
709
  );
628
710
  }
629
711
 
712
+ /**
713
+ * Split/Unified selector mounted into the `MainAreaWidget` toolbar. Mirrors
714
+ * {@link NotebookViewModeToolbarItem}: it reads from and writes to the
715
+ * {@link DiffContentWidget} so toolbar interactions and React state stay in
716
+ * sync, and only appears while the textual/code file diff is the active view
717
+ * (driven by the same `fileDiffActive` signal that gates toolbar visibility).
718
+ */
719
+ class DiffStyleToolbarItem extends ReactWidget {
720
+ constructor(content: DiffContentWidget) {
721
+ super();
722
+ this._content = content;
723
+ this.addClass('jp-xtralab-DiffWidget-diffStyleToolbarItem');
724
+ }
725
+
726
+ protected render(): React.ReactElement {
727
+ return <DiffStyleToolbarControl content={this._content} />;
728
+ }
729
+
730
+ private _content: DiffContentWidget;
731
+ }
732
+
733
+ function DiffStyleToolbarControl(props: {
734
+ content: DiffContentWidget;
735
+ }): React.ReactElement {
736
+ const { content } = props;
737
+ const [style, setStyle] = React.useState<DiffStyle>(() => content.diffStyle);
738
+ const [available, setAvailable] = React.useState<boolean>(
739
+ () => content.fileDiffActive
740
+ );
741
+ React.useEffect(() => {
742
+ const onStyle = (_sender: DiffContentWidget, next: DiffStyle): void => {
743
+ setStyle(next);
744
+ };
745
+ const onAvailable = (_sender: DiffContentWidget, next: boolean): void => {
746
+ setAvailable(next);
747
+ };
748
+ content.diffStyleChanged.connect(onStyle);
749
+ content.fileDiffActiveChanged.connect(onAvailable);
750
+ setStyle(content.diffStyle);
751
+ setAvailable(content.fileDiffActive);
752
+ return () => {
753
+ content.diffStyleChanged.disconnect(onStyle);
754
+ content.fileDiffActiveChanged.disconnect(onAvailable);
755
+ };
756
+ }, [content]);
757
+
758
+ return (
759
+ <DiffStyleControl
760
+ diffStyle={style}
761
+ available={available}
762
+ onChange={next => content.setDiffStyle(next)}
763
+ />
764
+ );
765
+ }
766
+
630
767
  function formatTitle(change: IFileChange): string {
631
768
  const name = change.path.split('/').pop() ?? change.path;
632
769
  const groupLabel = change.group === 'staged' ? 'Staged' : 'Working';
@@ -3,6 +3,7 @@ import * as React from 'react';
3
3
  import type { CommandRegistry } from '@lumino/commands';
4
4
  import type { ReadonlyPartialJSONObject } from '@lumino/coreutils';
5
5
  import { Poll } from '@lumino/polling';
6
+ import type { IDocumentWidget } from '@jupyterlab/docregistry';
6
7
  import {
7
8
  LabIcon,
8
9
  ReactWidget,
@@ -193,13 +194,28 @@ function LauncherDashboardComponent(
193
194
  }, [commands, cwd, onAgentLaunch]);
194
195
 
195
196
  const launchNotebook = React.useCallback(async () => {
196
- // No `kernelName` — let the notebook extension fall back to the default
197
- // kernel or prompt the user, matching the stock launcher's notebook
198
- // tile behavior.
199
- const result = (await commands.execute('notebook:create-new', {
200
- cwd
201
- })) as Widget | undefined;
197
+ // Open a fresh notebook with no kernel attached — equivalent to clicking
198
+ // "No Kernel" in the Select Kernel dialog. `notebook:create-new` always
199
+ // routes through that dialog when no kernelName is given, so drive the
200
+ // underlying docmanager steps directly and pass `shouldStart: false` to
201
+ // suppress kernel selection. The user picks a kernel from the notebook
202
+ // toolbar when they're ready.
203
+ const file = (await commands.execute('docmanager:new-untitled', {
204
+ path: cwd,
205
+ type: 'notebook'
206
+ })) as { path: string } | undefined;
207
+ if (!file) {
208
+ return;
209
+ }
210
+ const result = (await commands.execute('docmanager:open', {
211
+ path: file.path,
212
+ factory: 'Notebook',
213
+ kernelPreference: { shouldStart: false, canStart: true }
214
+ })) as IDocumentWidget | undefined;
202
215
  if (result) {
216
+ // Match `notebook:create-new`'s post-open marker so the first manual
217
+ // save offers a rename instead of overwriting the auto-generated name.
218
+ result.isUntitled = true;
203
219
  onAgentLaunch(result);
204
220
  }
205
221
  }, [commands, cwd, onAgentLaunch]);
@@ -86,6 +86,19 @@ const plugin: JupyterFrontEndPlugin<void> = {
86
86
  const { commands } = app;
87
87
  const trans = (translator ?? nullTranslator).load('jupyterlab');
88
88
 
89
+ // The upstream toggle-{left,right}-area commands derive `isEnabled` from
90
+ // `!labShell.isEmpty(side)` but don't fire `commandChanged` when widgets
91
+ // move in or out of a side area. `CommandToolbarButton` only re-renders on
92
+ // `commandChanged` for its own id, so the buttons need an explicit notify
93
+ // whenever the shell layout shifts — `layoutModified` covers each side
94
+ // handler's `_updated` (add/remove, expand/collapse), keeping both
95
+ // `isEnabled` and `isToggled` in sync.
96
+ labShell.layoutModified.connect(() => {
97
+ for (const spec of BUTTONS) {
98
+ commands.notifyCommandChanged(spec.command);
99
+ }
100
+ });
101
+
89
102
  // Defer to `app.restored` so the upstream toggle commands are already in
90
103
  // the registry: `CommandToolbarButton` renders nothing until its command
91
104
  // exists and only re-renders on `commandChanged` of type `changed` /