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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # xtralab
1
+ ![xtralab-logo](./logo.png)
2
2
 
3
3
  An opinionated JupyterLab meta-package for use with coding agents.
4
4
 
@@ -38,9 +38,10 @@ build instructions.
38
38
 
39
39
  xtralab builds on [`ajlab`](https://github.com/jtpio/ajlab), the agent-ready
40
40
  JupyterLab base. On top of it, xtralab pulls in JupyterLab 4.6+ with
41
- `jupyterlab-git`, `jupyterlab-lsp`, `jupyterlab-quickopen`, `jupyterlab-vim`,
42
- and a set of light and dark themes (`jupyterlab-cursor-light`,
43
- `jupyterlab-cursor-dark`, `jupyterlab-day`, `jupyterlab-night`). See
41
+ `jupyterlab-git`, `jupyterlab-lsp`, `jupyterlab-quickopen`,
42
+ `jupyterlab-search-replace`, `jupyterlab-vim`, and a set of light and dark
43
+ themes (`jupyterlab-cursor-light`, `jupyterlab-cursor-dark`,
44
+ `jupyterlab-day`, `jupyterlab-night`). See
44
45
  [`pyproject.toml`](./pyproject.toml) for the full list and pinned versions.
45
46
 
46
47
  The bundled xtralab labextension then adds:
@@ -82,6 +83,36 @@ See the [`jupyter-server-mcp` README][mcp] for other MCP clients.
82
83
  [mcp]: https://github.com/jupyter-ai-contrib/jupyter-server-mcp
83
84
  [mcp-spec]: https://modelcontextprotocol.io
84
85
 
86
+ ## Agent skill
87
+
88
+ xtralab ships an [Agent Skills][skills] entry at [`agent-skill/`](./agent-skill)
89
+ that teaches any coding agent how to customize JupyterLab from plain-English
90
+ requests like "hide the status bar", "change the theme", or "add a language
91
+ server". The same SKILL.md file works with Claude Code, Codex CLI, Gemini CLI,
92
+ GitHub Copilot, and other tools that read the Agent Skills format.
93
+
94
+ For Claude Code, this repository doubles as a one-plugin marketplace. Inside a
95
+ Claude Code session:
96
+
97
+ ```text
98
+ /plugin marketplace add jtpio/xtralab
99
+ /plugin install xtralab-skills@xtralab
100
+ ```
101
+
102
+ For agents that read from `~/.agents/skills/`, such as Codex CLI or Gemini CLI,
103
+ clone and copy the skill directory:
104
+
105
+ ```bash
106
+ git clone --depth=1 https://github.com/jtpio/xtralab.git /tmp/xtralab
107
+ mkdir -p ~/.agents/skills
108
+ cp -r /tmp/xtralab/agent-skill/skills/customize-jupyterlab ~/.agents/skills/
109
+ ```
110
+
111
+ See [`agent-skill/README.md`](./agent-skill/README.md) for additional install
112
+ paths, the list of supported agents, and what the skill knows.
113
+
114
+ [skills]: https://agentskills.io
115
+
85
116
  ## Language servers
86
117
 
87
118
  xtralab ships [`jupyterlab-lsp`][lsp] with two language servers pre-registered:
@@ -6,7 +6,7 @@ import { ReactWidget } from '@jupyterlab/ui-components';
6
6
  import { Git, IGitExtension } from '@jupyterlab/git';
7
7
  import { PromiseDelegate } from '@lumino/coreutils';
8
8
  import { Signal } from '@lumino/signaling';
9
- import { DIFF_WIDGET_CSS_CLASS, DiffSurface, NotebookViewModeControl, isDarkTheme, readStoredNotebookViewMode, writeStoredNotebookViewMode } from './diffSurface';
9
+ import { DIFF_WIDGET_CSS_CLASS, DiffStyleControl, DiffSurface, NotebookViewModeControl, isDarkTheme, readStoredDiffStyle, readStoredNotebookViewMode, writeStoredDiffStyle, writeStoredNotebookViewMode } from './diffSurface';
10
10
  import { IMAGE_DIFF_EXTENSIONS, imageDataType } from './imageDiff';
11
11
  /**
12
12
  * A `Git.Diff.IDiffWidget` that renders through {@link DiffSurface}.
@@ -25,9 +25,13 @@ class XtralabDiffWidget extends ReactWidget {
25
25
  this._notebookViewModeChanged = new Signal(this);
26
26
  this._hasNotebookView = false;
27
27
  this._hasNotebookViewChanged = new Signal(this);
28
+ this._diffStyleChanged = new Signal(this);
29
+ this._fileDiffActive = false;
30
+ this._fileDiffActiveChanged = new Signal(this);
28
31
  this._model = model;
29
32
  this._context = context;
30
33
  this._notebookViewMode = readStoredNotebookViewMode();
34
+ this._diffStyle = readStoredDiffStyle();
31
35
  // `jp-git-diff-root` borrows jupyterlab-git's host sizing (height:100%
32
36
  // flex column inside its PreviewMainAreaWidget); `jp-xtralab-DiffWidget`
33
37
  // brings our diff theming + the `.jp-xtralab-DiffWidget-*` styles.
@@ -102,6 +106,39 @@ class XtralabDiffWidget extends ReactWidget {
102
106
  get hasNotebookViewChanged() {
103
107
  return this._hasNotebookViewChanged;
104
108
  }
109
+ /** The user's current split-vs-unified layout choice for file diffs. */
110
+ get diffStyle() {
111
+ return this._diffStyle;
112
+ }
113
+ setDiffStyle(style) {
114
+ if (style === this._diffStyle) {
115
+ return;
116
+ }
117
+ this._diffStyle = style;
118
+ writeStoredDiffStyle(style);
119
+ this._diffStyleChanged.emit(style);
120
+ }
121
+ get diffStyleChanged() {
122
+ return this._diffStyleChanged;
123
+ }
124
+ /**
125
+ * Whether the textual/code file diff (the only view the split-vs-unified
126
+ * choice affects) is currently active; mirrored into the toolbar so the
127
+ * Split/Unified selector only shows when it applies.
128
+ */
129
+ get fileDiffActive() {
130
+ return this._fileDiffActive;
131
+ }
132
+ setFileDiffActive(value) {
133
+ if (value === this._fileDiffActive) {
134
+ return;
135
+ }
136
+ this._fileDiffActive = value;
137
+ this._fileDiffActiveChanged.emit(value);
138
+ }
139
+ get fileDiffActiveChanged() {
140
+ return this._fileDiffActiveChanged;
141
+ }
105
142
  /** Resolve the in-flight {@link refresh} promise, if any. */
106
143
  settleRefresh() {
107
144
  const pending = this._pendingRefresh;
@@ -146,6 +183,22 @@ function ModelDiffView(props) {
146
183
  const handleNotebookAvailabilityChange = React.useCallback((available) => {
147
184
  widget.setHasNotebookView(available);
148
185
  }, [widget]);
186
+ // Mirror the widget's diff-style choice so toolbar-driven changes
187
+ // re-render the diff. The widget owns the canonical value.
188
+ const [diffStyle, setDiffStyle] = React.useState(() => widget.diffStyle);
189
+ React.useEffect(() => {
190
+ const handler = (_sender, style) => {
191
+ setDiffStyle(style);
192
+ };
193
+ widget.diffStyleChanged.connect(handler);
194
+ setDiffStyle(widget.diffStyle);
195
+ return () => {
196
+ widget.diffStyleChanged.disconnect(handler);
197
+ };
198
+ }, [widget]);
199
+ const handleFileDiffActiveChange = React.useCallback((active) => {
200
+ widget.setFileDiffActive(active);
201
+ }, [widget]);
149
202
  const [dark, setDark] = React.useState(() => isDarkTheme(themeManager));
150
203
  React.useEffect(() => {
151
204
  if (themeManager !== null) {
@@ -232,7 +285,7 @@ function ModelDiffView(props) {
232
285
  void widget.refresh();
233
286
  }
234
287
  }), [canDiscardHunk, contentsManager, serverPath, widget]);
235
- return (React.createElement(DiffSurface, { loading: state.loading, error: state.error, isBinary: false, oldText: state.oldText, newText: state.newText, newName: model.filename, oldName: model.filename, dark: dark, rendermime: rendermime, notebookViewMode: notebookViewMode, onNotebookAvailabilityChange: handleNotebookAvailabilityChange, hunkDiscard: hunkDiscard }));
288
+ return (React.createElement(DiffSurface, { loading: state.loading, error: state.error, isBinary: false, oldText: state.oldText, newText: state.newText, newName: model.filename, oldName: model.filename, dark: dark, rendermime: rendermime, notebookViewMode: notebookViewMode, diffStyle: diffStyle, onNotebookAvailabilityChange: handleNotebookAvailabilityChange, onFileDiffActiveChange: handleFileDiffActiveChange, hunkDiscard: hunkDiscard }));
236
289
  }
237
290
  /**
238
291
  * Notebook/JSON toggle mounted into the diff toolbar that jupyterlab-git
@@ -271,6 +324,44 @@ function ToolbarControl(props) {
271
324
  }, [widget]);
272
325
  return (React.createElement(NotebookViewModeControl, { mode: mode, available: available, onChange: next => widget.setNotebookViewMode(next) }));
273
326
  }
327
+ /**
328
+ * Split/Unified toggle mounted into the diff toolbar jupyterlab-git passes
329
+ * to the factory. Bound to the widget's signals so it stays in sync with the
330
+ * rendered surface and only appears while the textual/code file diff is the
331
+ * active view.
332
+ */
333
+ class DiffStyleToolbarItem extends ReactWidget {
334
+ constructor(widget) {
335
+ super();
336
+ this._widget = widget;
337
+ this.addClass('jp-xtralab-DiffWidget-diffStyleToolbarItem');
338
+ }
339
+ render() {
340
+ return React.createElement(DiffStyleControlBound, { widget: this._widget });
341
+ }
342
+ }
343
+ function DiffStyleControlBound(props) {
344
+ const { widget } = props;
345
+ const [style, setStyle] = React.useState(() => widget.diffStyle);
346
+ const [available, setAvailable] = React.useState(() => widget.fileDiffActive);
347
+ React.useEffect(() => {
348
+ const onStyle = (_sender, next) => {
349
+ setStyle(next);
350
+ };
351
+ const onAvailable = (_sender, next) => {
352
+ setAvailable(next);
353
+ };
354
+ widget.diffStyleChanged.connect(onStyle);
355
+ widget.fileDiffActiveChanged.connect(onAvailable);
356
+ setStyle(widget.diffStyle);
357
+ setAvailable(widget.fileDiffActive);
358
+ return () => {
359
+ widget.diffStyleChanged.disconnect(onStyle);
360
+ widget.fileDiffActiveChanged.disconnect(onAvailable);
361
+ };
362
+ }, [widget]);
363
+ return (React.createElement(DiffStyleControl, { diffStyle: style, available: available, onChange: next => widget.setDiffStyle(next) }));
364
+ }
274
365
  /**
275
366
  * Build the `Git.Diff.Factory` xtralab registers with jupyterlab-git. The
276
367
  * factory closes over the application-level context (contents manager for
@@ -282,6 +373,7 @@ function makeXtralabDiffFactory(context) {
282
373
  const widget = new XtralabDiffWidget(options.model, context);
283
374
  if (options.toolbar) {
284
375
  options.toolbar.addItem('xtralab-notebook-view-mode', new NotebookViewModeToolbarItem(widget));
376
+ options.toolbar.addItem('xtralab-diff-style', new DiffStyleToolbarItem(widget));
285
377
  }
286
378
  return widget;
287
379
  };
@@ -21,6 +21,14 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
21
21
  * style the embedded `@pierre/diffs` viewer hang off this class.
22
22
  */
23
23
  export declare const DIFF_WIDGET_CSS_CLASS = "jp-xtralab-DiffWidget";
24
+ /**
25
+ * Layout for textual/code diffs, forwarded to `@pierre/diffs` as its
26
+ * `diffStyle` option. `split` is the side-by-side old|new view; `unified`
27
+ * is the single-column inline view (what diffs.com calls "stacked").
28
+ */
29
+ export type DiffStyle = 'split' | 'unified';
30
+ export declare function readStoredDiffStyle(): DiffStyle;
31
+ export declare function writeStoredDiffStyle(style: DiffStyle): void;
24
32
  /**
25
33
  * View modes available for `.ipynb` diffs. `notebook` is the cell-by-cell
26
34
  * rendered view; `json` is the raw nbformat JSON file diff, for inspecting
@@ -81,11 +89,20 @@ export interface IDiffSurfaceProps {
81
89
  rendermime: IRenderMimeRegistry | null;
82
90
  /** Current rendered-vs-JSON choice for notebook diffs (host-controlled). */
83
91
  notebookViewMode: NotebookDiffViewMode;
92
+ /** Split vs unified layout for the textual/code file diff (host-controlled). */
93
+ diffStyle: DiffStyle;
84
94
  /**
85
95
  * Called whenever the availability of a rendered notebook view changes,
86
96
  * so the host can show/hide its Notebook/JSON toolbar toggle.
87
97
  */
88
98
  onNotebookAvailabilityChange?: (available: boolean) => void;
99
+ /**
100
+ * Called whenever the textual/code file diff (the only view the split vs
101
+ * unified choice affects) becomes the active view, so the host can
102
+ * show/hide its Split/Unified toolbar toggle. False for image, binary,
103
+ * rendered-notebook, loading and error states.
104
+ */
105
+ onFileDiffActiveChange?: (active: boolean) => void;
89
106
  /**
90
107
  * Called after each (re)computation of the diff with the number of hunks
91
108
  * (or `null` when no textual diff exists). Hosts use this to implement
@@ -113,3 +130,15 @@ export declare function NotebookViewModeControl(props: {
113
130
  available: boolean;
114
131
  onChange: (mode: NotebookDiffViewMode) => void;
115
132
  }): React.ReactElement;
133
+ /**
134
+ * Segmented Split/Unified selector. Mirrors {@link NotebookViewModeControl}:
135
+ * hosts mount it into their own toolbar and drive its value/visibility from
136
+ * the same state they pass to {@link DiffSurface}. Only meaningful while the
137
+ * textual/code file diff is the active view, so `available` mirrors the
138
+ * surface's `onFileDiffActiveChange`.
139
+ */
140
+ export declare function DiffStyleControl(props: {
141
+ diffStyle: DiffStyle;
142
+ available: boolean;
143
+ onChange: (style: DiffStyle) => void;
144
+ }): React.ReactElement;
@@ -79,6 +79,27 @@ function writeStoredSplitRatio(ratio) {
79
79
  // See readStoredSplitRatio — best-effort persistence.
80
80
  }
81
81
  }
82
+ const DIFF_STYLE_STORAGE_KEY = 'xtralab:diff-style';
83
+ export function readStoredDiffStyle() {
84
+ try {
85
+ const raw = window.localStorage.getItem(DIFF_STYLE_STORAGE_KEY);
86
+ if (raw === 'split' || raw === 'unified') {
87
+ return raw;
88
+ }
89
+ }
90
+ catch (_a) {
91
+ // See readStoredSplitRatio — privacy-mode / sandboxed contexts can throw.
92
+ }
93
+ return 'split';
94
+ }
95
+ export function writeStoredDiffStyle(style) {
96
+ try {
97
+ window.localStorage.setItem(DIFF_STYLE_STORAGE_KEY, style);
98
+ }
99
+ catch (_a) {
100
+ // Best-effort.
101
+ }
102
+ }
82
103
  const NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY = 'xtralab:notebook-diff-view-mode';
83
104
  export function readStoredNotebookViewMode() {
84
105
  try {
@@ -129,7 +150,7 @@ export function isDarkTheme(themeManager) {
129
150
  * docstring for the division of labor between this surface and its hosts.
130
151
  */
131
152
  export function DiffSurface(props) {
132
- const { loading, error, isBinary, oldText, newText, newName, oldName, dark, rendermime, notebookViewMode, onNotebookAvailabilityChange, onMetadataChange, hunkDiscard } = props;
153
+ const { loading, error, isBinary, oldText, newText, newName, oldName, dark, rendermime, notebookViewMode, diffStyle, onNotebookAvailabilityChange, onFileDiffActiveChange, onMetadataChange, hunkDiscard } = props;
133
154
  const hasContent = !loading && error === null && !isBinary;
134
155
  // Raster images take a dedicated `<img>`-based view instead of a
135
156
  // text/notebook diff; the host passes the two sides as base64 strings in
@@ -171,6 +192,19 @@ export function DiffSurface(props) {
171
192
  hunkCount: metadata !== null ? metadata.hunks.length : null
172
193
  });
173
194
  }, [onMetadataChange, metadata]);
195
+ // Decide which view to render. The notebook view requires a successful
196
+ // nbformat parse; if that failed, we only show the raw JSON file diff.
197
+ // Computed before the early returns below so the file-diff-active effect
198
+ // can depend on it without violating the rules of hooks.
199
+ const hasNotebookView = notebookDiff !== null;
200
+ const showNotebookView = hasNotebookView && notebookViewMode === 'notebook';
201
+ const showFileDiff = !showNotebookView && metadata !== null;
202
+ // The split vs unified choice only affects the textual/code file diff, so
203
+ // tell the host whether that view is active to drive its Split/Unified
204
+ // toolbar toggle.
205
+ React.useEffect(() => {
206
+ onFileDiffActiveChange === null || onFileDiffActiveChange === void 0 ? void 0 : onFileDiffActiveChange(showFileDiff);
207
+ }, [onFileDiffActiveChange, showFileDiff]);
174
208
  // Split ratio for the diff columns: fraction of width given to the
175
209
  // deletions (left) pane. Persisted across sessions so the user only
176
210
  // dials in their layout once.
@@ -283,11 +317,6 @@ export function DiffSurface(props) {
283
317
  return (React.createElement("div", { className: "jp-xtralab-DiffWidget-content" },
284
318
  React.createElement(ImageDiffView, { reference: oldText, challenger: newText, fileType: imageType })));
285
319
  }
286
- // Decide which view to render. The notebook view requires a successful
287
- // nbformat parse; if that failed, we only show the raw JSON file diff.
288
- const hasNotebookView = notebookDiff !== null;
289
- const showNotebookView = hasNotebookView && notebookViewMode === 'notebook';
290
- const showFileDiff = !showNotebookView && metadata !== null;
291
320
  if (!showNotebookView && !showFileDiff) {
292
321
  return (React.createElement("div", { className: "jp-xtralab-DiffWidget-status" }, "No content to diff."));
293
322
  }
@@ -309,7 +338,7 @@ export function DiffSurface(props) {
309
338
  // crashes on instantiation. Running on the main thread is
310
339
  // fine for the small files git diffs typically operate on.
311
340
  disableWorkerPool: true, options: {
312
- diffStyle: 'split',
341
+ diffStyle,
313
342
  // Drop the file header — the tab title already shows the
314
343
  // file name and the panel header carries the change
315
344
  // context.
@@ -323,7 +352,7 @@ export function DiffSurface(props) {
323
352
  // changes.
324
353
  unsafeCSS: SPLIT_RESIZE_CSS
325
354
  } })) : null),
326
- showFileDiff ? (React.createElement("div", { className: "jp-xtralab-DiffWidget-resizer", style: { left: `${leftPercent}%` }, role: "separator", "aria-orientation": "vertical", "aria-valuenow": Math.round(leftPercent), "aria-valuemin": Math.round(MIN_SPLIT_RATIO * 100), "aria-valuemax": Math.round(MAX_SPLIT_RATIO * 100), title: "Drag to resize the diff panes (double-click to reset)", onPointerDown: handleResizerPointerDown, onDoubleClick: handleResizerDoubleClick })) : null)));
355
+ showFileDiff && diffStyle === 'split' ? (React.createElement("div", { className: "jp-xtralab-DiffWidget-resizer", style: { left: `${leftPercent}%` }, role: "separator", "aria-orientation": "vertical", "aria-valuenow": Math.round(leftPercent), "aria-valuemin": Math.round(MIN_SPLIT_RATIO * 100), "aria-valuemax": Math.round(MAX_SPLIT_RATIO * 100), title: "Drag to resize the diff panes (double-click to reset)", onPointerDown: handleResizerPointerDown, onDoubleClick: handleResizerDoubleClick })) : null)));
327
356
  }
328
357
  /**
329
358
  * Segmented Notebook/JSON selector. Hosts mount this into whatever toolbar
@@ -340,3 +369,38 @@ export function NotebookViewModeControl(props) {
340
369
  React.createElement("button", { type: "button", role: "tab", "aria-selected": mode === 'notebook', "data-active": mode === 'notebook', className: "jp-xtralab-DiffWidget-segmentedButton", onClick: () => onChange('notebook') }, "Notebook"),
341
370
  React.createElement("button", { type: "button", role: "tab", "aria-selected": mode === 'json', "data-active": mode === 'json', className: "jp-xtralab-DiffWidget-segmentedButton", onClick: () => onChange('json') }, "JSON")));
342
371
  }
372
+ /**
373
+ * Split / unified glyphs, inlined from the `@pierre/diffs` icon sprite
374
+ * (`diffs-icon-diff-split` / `diffs-icon-diff-unified`) so the toggle looks
375
+ * like the one on the library's own docs site without depending on the
376
+ * sprite sheet being injected into the document. `currentColor` lets the
377
+ * segmented-button styling drive the fill (including the active state).
378
+ */
379
+ function DiffSplitIcon() {
380
+ return (React.createElement("svg", { viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true" },
381
+ React.createElement("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" }),
382
+ React.createElement("path", { 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", opacity: ".3" })));
383
+ }
384
+ function DiffUnifiedIcon() {
385
+ return (React.createElement("svg", { viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true" },
386
+ React.createElement("path", { fillRule: "evenodd", 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", clipRule: "evenodd" }),
387
+ React.createElement("path", { fillRule: "evenodd", 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", clipRule: "evenodd", opacity: ".4" })));
388
+ }
389
+ /**
390
+ * Segmented Split/Unified selector. Mirrors {@link NotebookViewModeControl}:
391
+ * hosts mount it into their own toolbar and drive its value/visibility from
392
+ * the same state they pass to {@link DiffSurface}. Only meaningful while the
393
+ * textual/code file diff is the active view, so `available` mirrors the
394
+ * surface's `onFileDiffActiveChange`.
395
+ */
396
+ export function DiffStyleControl(props) {
397
+ const { diffStyle, available, onChange } = props;
398
+ if (!available) {
399
+ return React.createElement(React.Fragment, null);
400
+ }
401
+ return (React.createElement("div", { className: "jp-xtralab-DiffWidget-segmented", role: "tablist", "aria-label": "Diff view style" },
402
+ React.createElement("button", { type: "button", role: "tab", "aria-selected": diffStyle === 'split', "data-active": diffStyle === 'split', className: "jp-xtralab-DiffWidget-segmentedButton jp-xtralab-DiffWidget-segmentedButton-icon", title: "Split (side-by-side) view", "aria-label": "Split view", onClick: () => onChange('split') },
403
+ React.createElement(DiffSplitIcon, null)),
404
+ React.createElement("button", { type: "button", role: "tab", "aria-selected": diffStyle === 'unified', "data-active": diffStyle === 'unified', className: "jp-xtralab-DiffWidget-segmentedButton jp-xtralab-DiffWidget-segmentedButton-icon", title: "Unified (inline) view", "aria-label": "Unified view", onClick: () => onChange('unified') },
405
+ React.createElement(DiffUnifiedIcon, null))));
406
+ }
@@ -4,7 +4,7 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
4
4
  import { Contents } from '@jupyterlab/services';
5
5
  import { ReactWidget } from '@jupyterlab/ui-components';
6
6
  import { ISignal } from '@lumino/signaling';
7
- import { DIFF_WIDGET_CSS_CLASS, type NotebookDiffViewMode } from './diffSurface';
7
+ import { DIFF_WIDGET_CSS_CLASS, type DiffStyle, type NotebookDiffViewMode } from './diffSurface';
8
8
  import { GitReference, IFileChange } from './tokens';
9
9
  export { DIFF_WIDGET_CSS_CLASS };
10
10
  export declare const PREVIEW_DIFF_WIDGET_ID = "xtralab:diff:preview";
@@ -99,6 +99,21 @@ export declare class DiffContentWidget extends ReactWidget {
99
99
  setHasNotebookView(value: boolean): void;
100
100
  /** Emits whenever {@link hasNotebookView} changes. */
101
101
  get hasNotebookViewChanged(): ISignal<this, boolean>;
102
+ /** The user's current split-vs-unified layout choice for file diffs. */
103
+ get diffStyle(): DiffStyle;
104
+ /** Update the diff layout and persist it across sessions. */
105
+ setDiffStyle(style: DiffStyle): void;
106
+ /** Emits whenever {@link diffStyle} changes. */
107
+ get diffStyleChanged(): ISignal<this, DiffStyle>;
108
+ /**
109
+ * Whether the textual/code file diff (the only view the split-vs-unified
110
+ * choice affects) is currently active. Set by the React content; the
111
+ * toolbar reads it to decide whether to show the Split/Unified selector.
112
+ */
113
+ get fileDiffActive(): boolean;
114
+ setFileDiffActive(value: boolean): void;
115
+ /** Emits whenever {@link fileDiffActive} changes. */
116
+ get fileDiffActiveChanged(): ISignal<this, boolean>;
102
117
  get pinned(): boolean;
103
118
  pin(): void;
104
119
  get pinnedChanged(): ISignal<this, boolean>;
@@ -109,6 +124,10 @@ export declare class DiffContentWidget extends ReactWidget {
109
124
  private _notebookViewModeChanged;
110
125
  private _hasNotebookView;
111
126
  private _hasNotebookViewChanged;
127
+ private _diffStyle;
128
+ private _diffStyleChanged;
129
+ private _fileDiffActive;
130
+ private _fileDiffActiveChanged;
112
131
  private _pinned;
113
132
  private _pinnedChanged;
114
133
  }
@@ -6,7 +6,7 @@ import { Signal } from '@lumino/signaling';
6
6
  import { getTreeIcon } from '../fileBrowser/icons';
7
7
  import { content } from './api';
8
8
  import { imageDataType } from './imageDiff';
9
- import { DIFF_WIDGET_CSS_CLASS, DiffSurface, NotebookViewModeControl, isDarkTheme, readStoredNotebookViewMode, writeStoredNotebookViewMode } from './diffSurface';
9
+ import { DIFF_WIDGET_CSS_CLASS, DiffStyleControl, DiffSurface, NotebookViewModeControl, isDarkTheme, readStoredDiffStyle, readStoredNotebookViewMode, writeStoredDiffStyle, writeStoredNotebookViewMode } from './diffSurface';
10
10
  export { DIFF_WIDGET_CSS_CLASS };
11
11
  export const PREVIEW_DIFF_WIDGET_ID = 'xtralab:diff:preview';
12
12
  export function pinnedDiffWidgetId(change) {
@@ -101,18 +101,19 @@ export function createGitDiffWidget(options) {
101
101
  }
102
102
  widget.toolbar.addItem('pin', pinButton);
103
103
  widget.toolbar.addItem('notebook-view-mode', new NotebookViewModeToolbarItem(content));
104
+ widget.toolbar.addItem('diff-style', new DiffStyleToolbarItem(content));
104
105
  const syncToolbarVisibility = () => {
105
106
  if (widget === null || widget.isDisposed) {
106
107
  return;
107
108
  }
108
- if (!content.pinned || content.hasNotebookView) {
109
+ if (!content.pinned || content.hasNotebookView || content.fileDiffActive) {
109
110
  widget.toolbar.show();
110
111
  }
111
112
  else {
112
113
  widget.toolbar.hide();
113
114
  }
114
115
  };
115
- const onHasNotebookViewChanged = () => {
116
+ const onToolbarStateChanged = () => {
116
117
  if (widget === null || widget.isDisposed) {
117
118
  return;
118
119
  }
@@ -134,11 +135,13 @@ export function createGitDiffWidget(options) {
134
135
  }
135
136
  syncToolbarVisibility();
136
137
  };
137
- content.hasNotebookViewChanged.connect(onHasNotebookViewChanged);
138
+ content.hasNotebookViewChanged.connect(onToolbarStateChanged);
139
+ content.fileDiffActiveChanged.connect(onToolbarStateChanged);
138
140
  content.pinnedChanged.connect(onPinnedChanged);
139
141
  syncToolbarVisibility();
140
142
  widget.disposed.connect(() => {
141
- content.hasNotebookViewChanged.disconnect(onHasNotebookViewChanged);
143
+ content.hasNotebookViewChanged.disconnect(onToolbarStateChanged);
144
+ content.fileDiffActiveChanged.disconnect(onToolbarStateChanged);
142
145
  content.pinnedChanged.disconnect(onPinnedChanged);
143
146
  });
144
147
  return widget;
@@ -167,10 +170,14 @@ export class DiffContentWidget extends ReactWidget {
167
170
  this._notebookViewModeChanged = new Signal(this);
168
171
  this._hasNotebookView = false;
169
172
  this._hasNotebookViewChanged = new Signal(this);
173
+ this._diffStyleChanged = new Signal(this);
174
+ this._fileDiffActive = false;
175
+ this._fileDiffActiveChanged = new Signal(this);
170
176
  this._pinnedChanged = new Signal(this);
171
177
  this._options = options;
172
178
  this._pinned = options.pinned === true;
173
179
  this._notebookViewMode = readStoredNotebookViewMode();
180
+ this._diffStyle = readStoredDiffStyle();
174
181
  }
175
182
  /** The change this widget renders, used by callers to identify the widget. */
176
183
  get change() {
@@ -212,6 +219,42 @@ export class DiffContentWidget extends ReactWidget {
212
219
  get hasNotebookViewChanged() {
213
220
  return this._hasNotebookViewChanged;
214
221
  }
222
+ /** The user's current split-vs-unified layout choice for file diffs. */
223
+ get diffStyle() {
224
+ return this._diffStyle;
225
+ }
226
+ /** Update the diff layout and persist it across sessions. */
227
+ setDiffStyle(style) {
228
+ if (style === this._diffStyle) {
229
+ return;
230
+ }
231
+ this._diffStyle = style;
232
+ writeStoredDiffStyle(style);
233
+ this._diffStyleChanged.emit(style);
234
+ }
235
+ /** Emits whenever {@link diffStyle} changes. */
236
+ get diffStyleChanged() {
237
+ return this._diffStyleChanged;
238
+ }
239
+ /**
240
+ * Whether the textual/code file diff (the only view the split-vs-unified
241
+ * choice affects) is currently active. Set by the React content; the
242
+ * toolbar reads it to decide whether to show the Split/Unified selector.
243
+ */
244
+ get fileDiffActive() {
245
+ return this._fileDiffActive;
246
+ }
247
+ setFileDiffActive(value) {
248
+ if (value === this._fileDiffActive) {
249
+ return;
250
+ }
251
+ this._fileDiffActive = value;
252
+ this._fileDiffActiveChanged.emit(value);
253
+ }
254
+ /** Emits whenever {@link fileDiffActive} changes. */
255
+ get fileDiffActiveChanged() {
256
+ return this._fileDiffActiveChanged;
257
+ }
215
258
  get pinned() {
216
259
  return this._pinned;
217
260
  }
@@ -264,6 +307,25 @@ function DiffViewer(props) {
264
307
  widget.notebookViewModeChanged.disconnect(handler);
265
308
  };
266
309
  }, [widget]);
310
+ // Mirror the widget's diff-style choice the same way (the widget owns the
311
+ // canonical value so the toolbar can read and write it without going
312
+ // through React).
313
+ const [diffStyle, setDiffStyleLocal] = React.useState(() => widget.diffStyle);
314
+ React.useEffect(() => {
315
+ const handler = (_sender, style) => {
316
+ setDiffStyleLocal(style);
317
+ };
318
+ widget.diffStyleChanged.connect(handler);
319
+ setDiffStyleLocal(widget.diffStyle);
320
+ return () => {
321
+ widget.diffStyleChanged.disconnect(handler);
322
+ };
323
+ }, [widget]);
324
+ // Tell the host widget whether the textual/code file diff is the active
325
+ // view, so the toolbar's Split/Unified selector only shows when it applies.
326
+ const handleFileDiffActiveChange = React.useCallback((active) => {
327
+ widget.setFileDiffActive(active);
328
+ }, [widget]);
267
329
  // Tell the host widget whether a rendered notebook view is currently
268
330
  // available. The toolbar item that lives in the `MainAreaWidget`
269
331
  // toolbar reads this flag to decide whether to show the
@@ -408,7 +470,7 @@ function DiffViewer(props) {
408
470
  setReloadKey(key => key + 1);
409
471
  }
410
472
  }), [canDiscardHunk, contentsManager, onChanged, state.serverPath]);
411
- return (React.createElement(DiffSurface, { loading: state.loading, error: state.error, isBinary: state.isBinary, oldText: state.oldText, newText: state.newText, newName: change.path, oldName: (_a = change.from) !== null && _a !== void 0 ? _a : change.path, dark: dark, rendermime: rendermime, notebookViewMode: notebookViewMode, onNotebookAvailabilityChange: handleNotebookAvailabilityChange, onMetadataChange: handleMetadataChange, hunkDiscard: hunkDiscard }));
473
+ return (React.createElement(DiffSurface, { loading: state.loading, error: state.error, isBinary: state.isBinary, oldText: state.oldText, newText: state.newText, newName: change.path, oldName: (_a = change.from) !== null && _a !== void 0 ? _a : change.path, dark: dark, rendermime: rendermime, notebookViewMode: notebookViewMode, diffStyle: diffStyle, onNotebookAvailabilityChange: handleNotebookAvailabilityChange, onFileDiffActiveChange: handleFileDiffActiveChange, onMetadataChange: handleMetadataChange, hunkDiscard: hunkDiscard }));
412
474
  }
413
475
  /**
414
476
  * Segmented Notebook/JSON selector mounted into the `MainAreaWidget`
@@ -451,6 +513,45 @@ function NotebookViewModeToolbarControl(props) {
451
513
  }, [content]);
452
514
  return (React.createElement(NotebookViewModeControl, { mode: mode, available: available, onChange: next => content.setNotebookViewMode(next) }));
453
515
  }
516
+ /**
517
+ * Split/Unified selector mounted into the `MainAreaWidget` toolbar. Mirrors
518
+ * {@link NotebookViewModeToolbarItem}: it reads from and writes to the
519
+ * {@link DiffContentWidget} so toolbar interactions and React state stay in
520
+ * sync, and only appears while the textual/code file diff is the active view
521
+ * (driven by the same `fileDiffActive` signal that gates toolbar visibility).
522
+ */
523
+ class DiffStyleToolbarItem extends ReactWidget {
524
+ constructor(content) {
525
+ super();
526
+ this._content = content;
527
+ this.addClass('jp-xtralab-DiffWidget-diffStyleToolbarItem');
528
+ }
529
+ render() {
530
+ return React.createElement(DiffStyleToolbarControl, { content: this._content });
531
+ }
532
+ }
533
+ function DiffStyleToolbarControl(props) {
534
+ const { content } = props;
535
+ const [style, setStyle] = React.useState(() => content.diffStyle);
536
+ const [available, setAvailable] = React.useState(() => content.fileDiffActive);
537
+ React.useEffect(() => {
538
+ const onStyle = (_sender, next) => {
539
+ setStyle(next);
540
+ };
541
+ const onAvailable = (_sender, next) => {
542
+ setAvailable(next);
543
+ };
544
+ content.diffStyleChanged.connect(onStyle);
545
+ content.fileDiffActiveChanged.connect(onAvailable);
546
+ setStyle(content.diffStyle);
547
+ setAvailable(content.fileDiffActive);
548
+ return () => {
549
+ content.diffStyleChanged.disconnect(onStyle);
550
+ content.fileDiffActiveChanged.disconnect(onAvailable);
551
+ };
552
+ }, [content]);
553
+ return (React.createElement(DiffStyleControl, { diffStyle: style, available: available, onChange: next => content.setDiffStyle(next) }));
554
+ }
454
555
  function formatTitle(change) {
455
556
  var _a;
456
557
  const name = (_a = change.path.split('/').pop()) !== null && _a !== void 0 ? _a : change.path;
@@ -91,13 +91,28 @@ function LauncherDashboardComponent(props) {
91
91
  }
92
92
  }, [commands, cwd, onAgentLaunch]);
93
93
  const launchNotebook = React.useCallback(async () => {
94
- // No `kernelName` — let the notebook extension fall back to the default
95
- // kernel or prompt the user, matching the stock launcher's notebook
96
- // tile behavior.
97
- const result = (await commands.execute('notebook:create-new', {
98
- cwd
94
+ // Open a fresh notebook with no kernel attached — equivalent to clicking
95
+ // "No Kernel" in the Select Kernel dialog. `notebook:create-new` always
96
+ // routes through that dialog when no kernelName is given, so drive the
97
+ // underlying docmanager steps directly and pass `shouldStart: false` to
98
+ // suppress kernel selection. The user picks a kernel from the notebook
99
+ // toolbar when they're ready.
100
+ const file = (await commands.execute('docmanager:new-untitled', {
101
+ path: cwd,
102
+ type: 'notebook'
103
+ }));
104
+ if (!file) {
105
+ return;
106
+ }
107
+ const result = (await commands.execute('docmanager:open', {
108
+ path: file.path,
109
+ factory: 'Notebook',
110
+ kernelPreference: { shouldStart: false, canStart: true }
99
111
  }));
100
112
  if (result) {
113
+ // Match `notebook:create-new`'s post-open marker so the first manual
114
+ // save offers a rename instead of overwriting the auto-generated name.
115
+ result.isUntitled = true;
101
116
  onAgentLaunch(result);
102
117
  }
103
118
  }, [commands, cwd, onAgentLaunch]);
@@ -52,6 +52,18 @@ const plugin = {
52
52
  activate: (app, labShell, translator) => {
53
53
  const { commands } = app;
54
54
  const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
55
+ // The upstream toggle-{left,right}-area commands derive `isEnabled` from
56
+ // `!labShell.isEmpty(side)` but don't fire `commandChanged` when widgets
57
+ // move in or out of a side area. `CommandToolbarButton` only re-renders on
58
+ // `commandChanged` for its own id, so the buttons need an explicit notify
59
+ // whenever the shell layout shifts — `layoutModified` covers each side
60
+ // handler's `_updated` (add/remove, expand/collapse), keeping both
61
+ // `isEnabled` and `isToggled` in sync.
62
+ labShell.layoutModified.connect(() => {
63
+ for (const spec of BUTTONS) {
64
+ commands.notifyCommandChanged(spec.command);
65
+ }
66
+ });
55
67
  // Defer to `app.restored` so the upstream toggle commands are already in
56
68
  // the registry: `CommandToolbarButton` renders nothing until its command
57
69
  // exists and only re-renders on `commandChanged` of type `changed` /