xtralab 0.15.3 → 0.15.4

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.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Horizontal scrollbars for the diff panes, shown in a bar the host pins to
3
+ * the bottom of the view. A pane is as tall as the whole diff, so its own
4
+ * scrollbar sits below the last line.
5
+ */
6
+ export declare class DiffScrollbar {
7
+ constructor(bar: HTMLElement);
8
+ /**
9
+ * Match the tracks to the panes rendered in the `diffs-container`.
10
+ */
11
+ sync(container: Element | null): void;
12
+ /**
13
+ * Remove the tracks and stop observing the panes.
14
+ */
15
+ dispose(): void;
16
+ private _createTrack;
17
+ private _clear;
18
+ private _measure;
19
+ private _bar;
20
+ private _resizeObserver;
21
+ private _tracks;
22
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Copy the horizontal scroll position; the 1px tolerance stops the echo
3
+ * between a pane and its track.
4
+ */
5
+ function mirrorScroll(from, to) {
6
+ if (Math.abs(to.scrollLeft - from.scrollLeft) >= 1) {
7
+ to.scrollLeft = from.scrollLeft;
8
+ }
9
+ }
10
+ /**
11
+ * Horizontal scrollbars for the diff panes, shown in a bar the host pins to
12
+ * the bottom of the view. A pane is as tall as the whole diff, so its own
13
+ * scrollbar sits below the last line.
14
+ */
15
+ export class DiffScrollbar {
16
+ constructor(bar) {
17
+ this._tracks = [];
18
+ this._bar = bar;
19
+ this._resizeObserver = new ResizeObserver(() => this._measure());
20
+ }
21
+ /**
22
+ * Match the tracks to the panes rendered in the `diffs-container`.
23
+ */
24
+ sync(container) {
25
+ var _a, _b;
26
+ const panes = Array.from((_b = (_a = container === null || container === void 0 ? void 0 : container.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelectorAll('[data-code]')) !== null && _b !== void 0 ? _b : []);
27
+ const unchanged = panes.length === this._tracks.length &&
28
+ panes.every((pane, index) => pane === this._tracks[index].pane);
29
+ if (!unchanged) {
30
+ this._clear();
31
+ this._tracks = panes.map(pane => this._createTrack(pane));
32
+ }
33
+ // The Pierre themes give the diff its own background color.
34
+ this._bar.style.backgroundColor =
35
+ container === null ? '' : getComputedStyle(container).backgroundColor;
36
+ this._measure();
37
+ }
38
+ /**
39
+ * Remove the tracks and stop observing the panes.
40
+ */
41
+ dispose() {
42
+ this._clear();
43
+ }
44
+ _createTrack(pane) {
45
+ const track = document.createElement('div');
46
+ track.className = 'jp-xtralab-DiffWidget-scrollbarTrack';
47
+ track.tabIndex = -1;
48
+ const spacer = document.createElement('div');
49
+ track.appendChild(spacer);
50
+ const onPaneScroll = () => mirrorScroll(pane, track);
51
+ pane.addEventListener('scroll', onPaneScroll, { passive: true });
52
+ track.addEventListener('scroll', () => mirrorScroll(track, pane), {
53
+ passive: true
54
+ });
55
+ this._resizeObserver.observe(pane);
56
+ this._bar.appendChild(track);
57
+ return { pane, track, spacer, onPaneScroll };
58
+ }
59
+ _clear() {
60
+ this._resizeObserver.disconnect();
61
+ for (const { pane, onPaneScroll } of this._tracks) {
62
+ pane.removeEventListener('scroll', onPaneScroll);
63
+ }
64
+ this._tracks = [];
65
+ this._bar.replaceChildren();
66
+ }
67
+ _measure() {
68
+ const sizes = this._tracks.map(({ pane }) => ({
69
+ width: pane.getBoundingClientRect().width,
70
+ range: Math.max(0, pane.scrollWidth - pane.clientWidth)
71
+ }));
72
+ this._tracks.forEach(({ track, spacer }, index) => {
73
+ const { width, range } = sizes[index];
74
+ track.style.width = `${width}px`;
75
+ spacer.style.width = `${width + range}px`;
76
+ });
77
+ this._bar.hidden = !sizes.some(({ range }) => range > 0);
78
+ for (const { pane, track } of this._tracks) {
79
+ mirrorScroll(pane, track);
80
+ }
81
+ }
82
+ }
@@ -6,7 +6,8 @@ import { diffAcceptRejectHunk, parseDiffFromFile } from '@pierre/diffs';
6
6
  import { ImageDiffView, imageDataType } from './imageDiff';
7
7
  import { buildNotebookDiff, NotebookDiffView } from './notebookDiff';
8
8
  import { DEFAULT_SPLIT_RATIO, MAX_SPLIT_RATIO, MIN_SPLIT_RATIO } from './diffPreferences';
9
- import { DIFF_SCROLLBAR_CSS, resolveDiffTheme } from './diffTheme';
9
+ import { DiffScrollbar } from './diffScrollbar';
10
+ import { DIFF_HIDDEN_SCROLLBAR_CSS, resolveDiffTheme } from './diffTheme';
10
11
  export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget';
11
12
  /**
12
13
  * Injected via the `unsafeCSS` option into the shadow root's `@layer
@@ -17,7 +18,7 @@ export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget';
17
18
  const SPLIT_RESIZE_CSS = `pre[data-diff-type="split"][data-overflow="scroll"] {
18
19
  grid-template-columns: var(--xtralab-split-cols, 1fr 1fr);
19
20
  }`;
20
- const DIFF_SURFACE_CSS = `${SPLIT_RESIZE_CSS}\n${DIFF_SCROLLBAR_CSS}`;
21
+ const DIFF_SURFACE_CSS = `${SPLIT_RESIZE_CSS}\n${DIFF_HIDDEN_SCROLLBAR_CSS}`;
21
22
  /**
22
23
  * Extension-only check; a false positive is harmless because
23
24
  * {@link buildNotebookDiff} validates the JSON and the file diff is the fallback.
@@ -97,6 +98,27 @@ export function DiffSurface(props) {
97
98
  React.useEffect(() => {
98
99
  leftRatioRef.current = leftRatio;
99
100
  }, [leftRatio]);
101
+ const scrollbarNodeRef = React.useRef(null);
102
+ const scrollbarRef = React.useRef(null);
103
+ React.useEffect(() => {
104
+ var _a, _b;
105
+ const node = scrollbarNodeRef.current;
106
+ if (node === null) {
107
+ return;
108
+ }
109
+ const scrollbar = new DiffScrollbar(node);
110
+ scrollbarRef.current = scrollbar;
111
+ // The library's first render ran before this effect.
112
+ scrollbar.sync((_b = (_a = wrapperRef.current) === null || _a === void 0 ? void 0 : _a.querySelector('diffs-container')) !== null && _b !== void 0 ? _b : null);
113
+ return () => {
114
+ scrollbarRef.current = null;
115
+ scrollbar.dispose();
116
+ };
117
+ }, [showFileDiff]);
118
+ const handlePostRender = React.useCallback((container) => {
119
+ var _a;
120
+ (_a = scrollbarRef.current) === null || _a === void 0 ? void 0 : _a.sync(container);
121
+ }, []);
100
122
  const canDiscardHunk = (hunkDiscard === null || hunkDiscard === void 0 ? void 0 : hunkDiscard.enabled) === true;
101
123
  const lineAnnotations = React.useMemo(() => {
102
124
  if (!canDiscardHunk || metadata === null) {
@@ -231,25 +253,28 @@ export function DiffSurface(props) {
231
253
  };
232
254
  return (React.createElement("div", { className: "jp-xtralab-DiffWidget-content" },
233
255
  React.createElement("div", { ref: wrapperRef, className: "jp-xtralab-DiffWidget-body" },
234
- React.createElement("div", { className: "jp-xtralab-DiffWidget-scroll" }, showNotebookView && notebookDiff !== null ? (React.createElement(NotebookDiffView, { diff: notebookDiff, dark: dark, pierreTheme: pierreTheme, rendermime: rendermime, trans: trans })) : showFileDiff && metadata !== null ? (React.createElement(FileDiff, { fileDiff: metadata, lineAnnotations: lineAnnotations, renderAnnotation: renderAnnotation, style: hostStyle,
235
- // JupyterLab's federation pipeline can't serve the worker bundle
236
- // at a resolvable URL, so the pool crashes; run on the main thread.
237
- disableWorkerPool: true, options: {
238
- diffStyle,
239
- overflow: lineWrap ? 'wrap' : 'scroll',
240
- disableFileHeader: true,
241
- theme: resolveDiffTheme(dark, pierreTheme),
242
- themeType: dark ? 'dark' : 'light',
243
- // Constant string lets the library skip its unsafeCSS re-render path.
244
- unsafeCSS: DIFF_SURFACE_CSS,
245
- ...(onLineAsk !== undefined
246
- ? {
247
- enableLineSelection: true,
248
- enableGutterUtility: true,
249
- onGutterUtilityClick: handleGutterUtilityClick
250
- }
251
- : {})
252
- } })) : null),
256
+ React.createElement("div", { className: "jp-xtralab-DiffWidget-scroll" },
257
+ showNotebookView && notebookDiff !== null ? (React.createElement(NotebookDiffView, { diff: notebookDiff, dark: dark, pierreTheme: pierreTheme, rendermime: rendermime, trans: trans })) : showFileDiff && metadata !== null ? (React.createElement(FileDiff, { fileDiff: metadata, lineAnnotations: lineAnnotations, renderAnnotation: renderAnnotation, style: hostStyle,
258
+ // JupyterLab's federation pipeline can't serve the worker bundle
259
+ // at a resolvable URL, so the pool crashes; run on the main thread.
260
+ disableWorkerPool: true, options: {
261
+ diffStyle,
262
+ overflow: lineWrap ? 'wrap' : 'scroll',
263
+ disableFileHeader: true,
264
+ theme: resolveDiffTheme(dark, pierreTheme),
265
+ themeType: dark ? 'dark' : 'light',
266
+ // Constant string lets the library skip its unsafeCSS re-render path.
267
+ unsafeCSS: DIFF_SURFACE_CSS,
268
+ onPostRender: handlePostRender,
269
+ ...(onLineAsk !== undefined
270
+ ? {
271
+ enableLineSelection: true,
272
+ enableGutterUtility: true,
273
+ onGutterUtilityClick: handleGutterUtilityClick
274
+ }
275
+ : {})
276
+ } })) : null,
277
+ showFileDiff ? (React.createElement("div", { ref: scrollbarNodeRef, className: "jp-xtralab-DiffWidget-scrollbar", "aria-hidden": "true" })) : null),
253
278
  showResizer ? (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), "aria-label": trans.__('Resize the diff panes'), tabIndex: 0, title: trans.__('Drag to resize the diff panes (double-click to reset)'), onPointerDown: handleResizerPointerDown, onDoubleClick: handleResizerDoubleClick, onKeyDown: handleResizerKeyDown })) : null)));
254
279
  }
255
280
  /**
@@ -6,6 +6,11 @@ import { type DiffsThemeNames } from '@pierre/diffs';
6
6
  * app scrollbars in chrome.css.
7
7
  */
8
8
  export declare const DIFF_SCROLLBAR_CSS = ":host {\n --diffs-scrollbar-gutter-override: 10px;\n --xtralab-diff-scrollbar-thumb: color-mix(in srgb, var(--jp-ui-font-color1) 28%, transparent);\n}\n[data-code]::-webkit-scrollbar-thumb {\n background-color: var(--xtralab-diff-scrollbar-thumb);\n border-width: 2px;\n border-radius: 999px;\n}\n[data-code]::-webkit-scrollbar-thumb:hover {\n background-color: color-mix(in srgb, var(--jp-ui-font-color1) 45%, transparent);\n}\n@supports (-moz-appearance: none) {\n [data-code] {\n scrollbar-color: var(--xtralab-diff-scrollbar-thumb) transparent;\n }\n}";
9
+ /**
10
+ * `unsafeCSS` that hides the pane scrollbars, for hosts that show a
11
+ * `DiffScrollbar` instead.
12
+ */
13
+ export declare const DIFF_HIDDEN_SCROLLBAR_CSS = ":host {\n --diffs-scrollbar-gutter-override: 0px;\n}\n[data-code] {\n scrollbar-width: none;\n}";
9
14
  /**
10
15
  * Choose Pierre's palette or the JupyterLab CSS-variable theme.
11
16
  */
@@ -43,6 +43,16 @@ export const DIFF_SCROLLBAR_CSS = `:host {
43
43
  scrollbar-color: var(--xtralab-diff-scrollbar-thumb) transparent;
44
44
  }
45
45
  }`;
46
+ /**
47
+ * `unsafeCSS` that hides the pane scrollbars, for hosts that show a
48
+ * `DiffScrollbar` instead.
49
+ */
50
+ export const DIFF_HIDDEN_SCROLLBAR_CSS = `:host {
51
+ --diffs-scrollbar-gutter-override: 0px;
52
+ }
53
+ [data-code] {
54
+ scrollbar-width: none;
55
+ }`;
46
56
  /**
47
57
  * Choose Pierre's palette or the JupyterLab CSS-variable theme.
48
58
  */
@@ -3,7 +3,8 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
3
3
  * Turns the OSC 9 / OSC 777 / bell sequences agents emit into desktop
4
4
  * notifications via `window.xtralab.notify` (desktop) or the web Notifications
5
5
  * API — JupyterLab's xterm renders them but never forwards them to the OS.
6
- * The desktop shell advertises `TERM_PROGRAM=iTerm.app` so agents emit OSC 9.
6
+ * The server extension sets `TERM_PROGRAM=iTerm.app` in terminals so agents
7
+ * emit OSC 9.
7
8
  */
8
9
  declare const plugin: JupyterFrontEndPlugin<void>;
9
10
  export default plugin;
@@ -1,3 +1,4 @@
1
+ import { Notification as LabNotification } from '@jupyterlab/apputils';
1
2
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
2
3
  import { ITerminalTracker } from '@jupyterlab/terminal';
3
4
  import { ITranslator, nullTranslator } from '@jupyterlab/translation';
@@ -15,7 +16,8 @@ const MAX_TEXT_LENGTH = 256;
15
16
  * Turns the OSC 9 / OSC 777 / bell sequences agents emit into desktop
16
17
  * notifications via `window.xtralab.notify` (desktop) or the web Notifications
17
18
  * API — JupyterLab's xterm renders them but never forwards them to the OS.
18
- * The desktop shell advertises `TERM_PROGRAM=iTerm.app` so agents emit OSC 9.
19
+ * The server extension sets `TERM_PROGRAM=iTerm.app` in terminals so agents
20
+ * emit OSC 9.
19
21
  */
20
22
  const plugin = {
21
23
  id: PLUGIN_ID,
@@ -31,6 +33,52 @@ const plugin = {
31
33
  const lastNotified = new WeakMap();
32
34
  const hooks = new WeakMap();
33
35
  const isActivelyViewing = (widget) => document.hasFocus() && app.shell.currentWidget === widget;
36
+ // `terminal:open` activates the session's tab or reopens the session.
37
+ const focusTerminal = (session) => {
38
+ if (session) {
39
+ void app.commands.execute('terminal:open', { name: session });
40
+ }
41
+ };
42
+ const showWebNotification = (title, body, session) => {
43
+ // The constructor throws where only service workers may notify (Chrome
44
+ // on Android).
45
+ try {
46
+ const notification = new Notification(title, { body });
47
+ notification.onclick = () => {
48
+ window.focus();
49
+ focusTerminal(session);
50
+ notification.close();
51
+ };
52
+ }
53
+ catch (reason) {
54
+ console.warn('xtralab: web notification failed', reason);
55
+ }
56
+ };
57
+ // Browsers only show the permission prompt from a user gesture (Firefox
58
+ // and Safari drop the request otherwise), which a terminal sequence is
59
+ // not: offer it once per page in a toast, whose button click is one.
60
+ let permissionOffered = false;
61
+ const offerWebNotifications = (title, body, session) => {
62
+ if (permissionOffered) {
63
+ return;
64
+ }
65
+ permissionOffered = true;
66
+ LabNotification.info(trans.__('A terminal sent a notification. Allow notifications in the browser to see them outside of this tab.'), {
67
+ autoClose: false,
68
+ actions: [
69
+ {
70
+ label: trans.__('Allow notifications'),
71
+ callback: () => {
72
+ void Notification.requestPermission().then(permission => {
73
+ if (permission === 'granted') {
74
+ showWebNotification(title, body, session);
75
+ }
76
+ });
77
+ }
78
+ }
79
+ ]
80
+ });
81
+ };
34
82
  const deliver = (title, body, session) => {
35
83
  const bridge = window.xtralab;
36
84
  if (bridge && typeof bridge.notify === 'function') {
@@ -43,14 +91,10 @@ const plugin = {
43
91
  return;
44
92
  }
45
93
  if (Notification.permission === 'granted') {
46
- new Notification(title, { body });
94
+ showWebNotification(title, body, session);
47
95
  }
48
96
  else if (Notification.permission !== 'denied') {
49
- void Notification.requestPermission().then(permission => {
50
- if (permission === 'granted') {
51
- new Notification(title, { body });
52
- }
53
- });
97
+ offerWebNotifications(title, body, session);
54
98
  }
55
99
  };
56
100
  const notify = (widget, title, body) => {
@@ -137,14 +181,10 @@ const plugin = {
137
181
  tracker.forEach(hookWidget);
138
182
  tracker.widgetAdded.connect((_, widget) => hookWidget(widget));
139
183
  // Focus the terminal that fired a notification when the desktop shell
140
- // reports a click. `terminal:open` activates its tab or reopens the session.
184
+ // reports a click.
141
185
  const desktopBridge = window
142
186
  .xtralab;
143
- (_a = desktopBridge === null || desktopBridge === void 0 ? void 0 : desktopBridge.onFocusTerminal) === null || _a === void 0 ? void 0 : _a.call(desktopBridge, session => {
144
- if (session) {
145
- void app.commands.execute('terminal:open', { name: session });
146
- }
147
- });
187
+ (_a = desktopBridge === null || desktopBridge === void 0 ? void 0 : desktopBridge.onFocusTerminal) === null || _a === void 0 ? void 0 : _a.call(desktopBridge, focusTerminal);
148
188
  if (settingRegistry) {
149
189
  try {
150
190
  const settings = await settingRegistry.load(PLUGIN_ID);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtralab",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
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",
@@ -0,0 +1,103 @@
1
+ /**
2
+ * One mirrored diff pane: the library's scroller and its track in the bar.
3
+ */
4
+ interface IPaneTrack {
5
+ pane: HTMLElement;
6
+ track: HTMLElement;
7
+ spacer: HTMLElement;
8
+ onPaneScroll: () => void;
9
+ }
10
+
11
+ /**
12
+ * Copy the horizontal scroll position; the 1px tolerance stops the echo
13
+ * between a pane and its track.
14
+ */
15
+ function mirrorScroll(from: HTMLElement, to: HTMLElement): void {
16
+ if (Math.abs(to.scrollLeft - from.scrollLeft) >= 1) {
17
+ to.scrollLeft = from.scrollLeft;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Horizontal scrollbars for the diff panes, shown in a bar the host pins to
23
+ * the bottom of the view. A pane is as tall as the whole diff, so its own
24
+ * scrollbar sits below the last line.
25
+ */
26
+ export class DiffScrollbar {
27
+ constructor(bar: HTMLElement) {
28
+ this._bar = bar;
29
+ this._resizeObserver = new ResizeObserver(() => this._measure());
30
+ }
31
+
32
+ /**
33
+ * Match the tracks to the panes rendered in the `diffs-container`.
34
+ */
35
+ sync(container: Element | null): void {
36
+ const panes = Array.from(
37
+ container?.shadowRoot?.querySelectorAll<HTMLElement>('[data-code]') ?? []
38
+ );
39
+ const unchanged =
40
+ panes.length === this._tracks.length &&
41
+ panes.every((pane, index) => pane === this._tracks[index].pane);
42
+ if (!unchanged) {
43
+ this._clear();
44
+ this._tracks = panes.map(pane => this._createTrack(pane));
45
+ }
46
+ // The Pierre themes give the diff its own background color.
47
+ this._bar.style.backgroundColor =
48
+ container === null ? '' : getComputedStyle(container).backgroundColor;
49
+ this._measure();
50
+ }
51
+
52
+ /**
53
+ * Remove the tracks and stop observing the panes.
54
+ */
55
+ dispose(): void {
56
+ this._clear();
57
+ }
58
+
59
+ private _createTrack(pane: HTMLElement): IPaneTrack {
60
+ const track = document.createElement('div');
61
+ track.className = 'jp-xtralab-DiffWidget-scrollbarTrack';
62
+ track.tabIndex = -1;
63
+ const spacer = document.createElement('div');
64
+ track.appendChild(spacer);
65
+ const onPaneScroll = (): void => mirrorScroll(pane, track);
66
+ pane.addEventListener('scroll', onPaneScroll, { passive: true });
67
+ track.addEventListener('scroll', () => mirrorScroll(track, pane), {
68
+ passive: true
69
+ });
70
+ this._resizeObserver.observe(pane);
71
+ this._bar.appendChild(track);
72
+ return { pane, track, spacer, onPaneScroll };
73
+ }
74
+
75
+ private _clear(): void {
76
+ this._resizeObserver.disconnect();
77
+ for (const { pane, onPaneScroll } of this._tracks) {
78
+ pane.removeEventListener('scroll', onPaneScroll);
79
+ }
80
+ this._tracks = [];
81
+ this._bar.replaceChildren();
82
+ }
83
+
84
+ private _measure(): void {
85
+ const sizes = this._tracks.map(({ pane }) => ({
86
+ width: pane.getBoundingClientRect().width,
87
+ range: Math.max(0, pane.scrollWidth - pane.clientWidth)
88
+ }));
89
+ this._tracks.forEach(({ track, spacer }, index) => {
90
+ const { width, range } = sizes[index];
91
+ track.style.width = `${width}px`;
92
+ spacer.style.width = `${width + range}px`;
93
+ });
94
+ this._bar.hidden = !sizes.some(({ range }) => range > 0);
95
+ for (const { pane, track } of this._tracks) {
96
+ mirrorScroll(pane, track);
97
+ }
98
+ }
99
+
100
+ private _bar: HTMLElement;
101
+ private _resizeObserver: ResizeObserver;
102
+ private _tracks: IPaneTrack[] = [];
103
+ }
@@ -30,7 +30,8 @@ import {
30
30
  type ImageDiffViewMode,
31
31
  type NotebookDiffViewMode
32
32
  } from './diffPreferences';
33
- import { DIFF_SCROLLBAR_CSS, resolveDiffTheme } from './diffTheme';
33
+ import { DiffScrollbar } from './diffScrollbar';
34
+ import { DIFF_HIDDEN_SCROLLBAR_CSS, resolveDiffTheme } from './diffTheme';
34
35
 
35
36
  export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget';
36
37
 
@@ -44,7 +45,7 @@ const SPLIT_RESIZE_CSS = `pre[data-diff-type="split"][data-overflow="scroll"] {
44
45
  grid-template-columns: var(--xtralab-split-cols, 1fr 1fr);
45
46
  }`;
46
47
 
47
- const DIFF_SURFACE_CSS = `${SPLIT_RESIZE_CSS}\n${DIFF_SCROLLBAR_CSS}`;
48
+ const DIFF_SURFACE_CSS = `${SPLIT_RESIZE_CSS}\n${DIFF_HIDDEN_SCROLLBAR_CSS}`;
48
49
 
49
50
  /**
50
51
  * Annotation payload threaded back into `renderAnnotation`; carries the target hunk index.
@@ -290,6 +291,29 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
290
291
  leftRatioRef.current = leftRatio;
291
292
  }, [leftRatio]);
292
293
 
294
+ const scrollbarNodeRef = React.useRef<HTMLDivElement | null>(null);
295
+ const scrollbarRef = React.useRef<DiffScrollbar | null>(null);
296
+ React.useEffect(() => {
297
+ const node = scrollbarNodeRef.current;
298
+ if (node === null) {
299
+ return;
300
+ }
301
+ const scrollbar = new DiffScrollbar(node);
302
+ scrollbarRef.current = scrollbar;
303
+ // The library's first render ran before this effect.
304
+ scrollbar.sync(
305
+ wrapperRef.current?.querySelector('diffs-container') ?? null
306
+ );
307
+ return () => {
308
+ scrollbarRef.current = null;
309
+ scrollbar.dispose();
310
+ };
311
+ }, [showFileDiff]);
312
+
313
+ const handlePostRender = React.useCallback((container: HTMLElement) => {
314
+ scrollbarRef.current?.sync(container);
315
+ }, []);
316
+
293
317
  const canDiscardHunk = hunkDiscard?.enabled === true;
294
318
 
295
319
  const lineAnnotations = React.useMemo<
@@ -530,6 +554,7 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
530
554
  themeType: dark ? 'dark' : 'light',
531
555
  // Constant string lets the library skip its unsafeCSS re-render path.
532
556
  unsafeCSS: DIFF_SURFACE_CSS,
557
+ onPostRender: handlePostRender,
533
558
  ...(onLineAsk !== undefined
534
559
  ? {
535
560
  enableLineSelection: true,
@@ -540,6 +565,13 @@ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
540
565
  }}
541
566
  />
542
567
  ) : null}
568
+ {showFileDiff ? (
569
+ <div
570
+ ref={scrollbarNodeRef}
571
+ className="jp-xtralab-DiffWidget-scrollbar"
572
+ aria-hidden="true"
573
+ />
574
+ ) : null}
543
575
  </div>
544
576
  {showResizer ? (
545
577
  <div
@@ -50,6 +50,17 @@ export const DIFF_SCROLLBAR_CSS = `:host {
50
50
  }
51
51
  }`;
52
52
 
53
+ /**
54
+ * `unsafeCSS` that hides the pane scrollbars, for hosts that show a
55
+ * `DiffScrollbar` instead.
56
+ */
57
+ export const DIFF_HIDDEN_SCROLLBAR_CSS = `:host {
58
+ --diffs-scrollbar-gutter-override: 0px;
59
+ }
60
+ [data-code] {
61
+ scrollbar-width: none;
62
+ }`;
63
+
53
64
  /**
54
65
  * Choose Pierre's palette or the JupyterLab CSS-variable theme.
55
66
  */
@@ -2,6 +2,7 @@ import {
2
2
  JupyterFrontEnd,
3
3
  JupyterFrontEndPlugin
4
4
  } from '@jupyterlab/application';
5
+ import { Notification as LabNotification } from '@jupyterlab/apputils';
5
6
  import type { MainAreaWidget } from '@jupyterlab/apputils';
6
7
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
7
8
  import { ITerminal, ITerminalTracker } from '@jupyterlab/terminal';
@@ -92,7 +93,8 @@ interface IDesktopBridge {
92
93
  * Turns the OSC 9 / OSC 777 / bell sequences agents emit into desktop
93
94
  * notifications via `window.xtralab.notify` (desktop) or the web Notifications
94
95
  * API — JupyterLab's xterm renders them but never forwards them to the OS.
95
- * The desktop shell advertises `TERM_PROGRAM=iTerm.app` so agents emit OSC 9.
96
+ * The server extension sets `TERM_PROGRAM=iTerm.app` in terminals so agents
97
+ * emit OSC 9.
96
98
  */
97
99
  const plugin: JupyterFrontEndPlugin<void> = {
98
100
  id: PLUGIN_ID,
@@ -118,6 +120,67 @@ const plugin: JupyterFrontEndPlugin<void> = {
118
120
  const isActivelyViewing = (widget: TerminalWidget): boolean =>
119
121
  document.hasFocus() && app.shell.currentWidget === widget;
120
122
 
123
+ // `terminal:open` activates the session's tab or reopens the session.
124
+ const focusTerminal = (session?: string): void => {
125
+ if (session) {
126
+ void app.commands.execute('terminal:open', { name: session });
127
+ }
128
+ };
129
+
130
+ const showWebNotification = (
131
+ title: string,
132
+ body: string,
133
+ session?: string
134
+ ): void => {
135
+ // The constructor throws where only service workers may notify (Chrome
136
+ // on Android).
137
+ try {
138
+ const notification = new Notification(title, { body });
139
+ notification.onclick = () => {
140
+ window.focus();
141
+ focusTerminal(session);
142
+ notification.close();
143
+ };
144
+ } catch (reason) {
145
+ console.warn('xtralab: web notification failed', reason);
146
+ }
147
+ };
148
+
149
+ // Browsers only show the permission prompt from a user gesture (Firefox
150
+ // and Safari drop the request otherwise), which a terminal sequence is
151
+ // not: offer it once per page in a toast, whose button click is one.
152
+ let permissionOffered = false;
153
+ const offerWebNotifications = (
154
+ title: string,
155
+ body: string,
156
+ session?: string
157
+ ): void => {
158
+ if (permissionOffered) {
159
+ return;
160
+ }
161
+ permissionOffered = true;
162
+ LabNotification.info(
163
+ trans.__(
164
+ 'A terminal sent a notification. Allow notifications in the browser to see them outside of this tab.'
165
+ ),
166
+ {
167
+ autoClose: false,
168
+ actions: [
169
+ {
170
+ label: trans.__('Allow notifications'),
171
+ callback: () => {
172
+ void Notification.requestPermission().then(permission => {
173
+ if (permission === 'granted') {
174
+ showWebNotification(title, body, session);
175
+ }
176
+ });
177
+ }
178
+ }
179
+ ]
180
+ }
181
+ );
182
+ };
183
+
121
184
  const deliver = (title: string, body: string, session?: string): void => {
122
185
  const bridge = (window as Window & { xtralab?: IDesktopBridge }).xtralab;
123
186
  if (bridge && typeof bridge.notify === 'function') {
@@ -132,13 +195,9 @@ const plugin: JupyterFrontEndPlugin<void> = {
132
195
  return;
133
196
  }
134
197
  if (Notification.permission === 'granted') {
135
- new Notification(title, { body });
198
+ showWebNotification(title, body, session);
136
199
  } else if (Notification.permission !== 'denied') {
137
- void Notification.requestPermission().then(permission => {
138
- if (permission === 'granted') {
139
- new Notification(title, { body });
140
- }
141
- });
200
+ offerWebNotifications(title, body, session);
142
201
  }
143
202
  };
144
203
 
@@ -245,14 +304,10 @@ const plugin: JupyterFrontEndPlugin<void> = {
245
304
  tracker.widgetAdded.connect((_, widget) => hookWidget(widget));
246
305
 
247
306
  // Focus the terminal that fired a notification when the desktop shell
248
- // reports a click. `terminal:open` activates its tab or reopens the session.
307
+ // reports a click.
249
308
  const desktopBridge = (window as Window & { xtralab?: IDesktopBridge })
250
309
  .xtralab;
251
- desktopBridge?.onFocusTerminal?.(session => {
252
- if (session) {
253
- void app.commands.execute('terminal:open', { name: session });
254
- }
255
- });
310
+ desktopBridge?.onFocusTerminal?.(focusTerminal);
256
311
 
257
312
  if (settingRegistry) {
258
313
  try {
package/style/git.css CHANGED
@@ -49,6 +49,75 @@
49
49
  overflow: auto;
50
50
  }
51
51
 
52
+ /*
53
+ * Stays at the bottom of the view while the diff scrolls under it.
54
+ */
55
+ .jp-xtralab-DiffWidget-scrollbar {
56
+ position: sticky;
57
+ bottom: 0;
58
+ display: flex;
59
+ }
60
+
61
+ .jp-xtralab-DiffWidget-scrollbar[hidden] {
62
+ display: none;
63
+ }
64
+
65
+ /*
66
+ * `auto` opts out of the thin app scrollbars in chrome.css, which hide
67
+ * themselves where the system uses overlay scrollbars.
68
+ */
69
+ body[data-jp-theme-light] .jp-xtralab-DiffWidget-scrollbarTrack {
70
+ flex: none;
71
+ height: 10px;
72
+ overflow: auto hidden;
73
+ overscroll-behavior-x: none;
74
+ scrollbar-width: auto;
75
+ scrollbar-color: auto;
76
+ }
77
+
78
+ .jp-xtralab-DiffWidget-scrollbarTrack > div {
79
+ height: 1px;
80
+ }
81
+
82
+ .jp-xtralab-DiffWidget-scrollbarTrack::-webkit-scrollbar {
83
+ height: 10px;
84
+ }
85
+
86
+ .jp-xtralab-DiffWidget-scrollbarTrack::-webkit-scrollbar-track {
87
+ background: transparent;
88
+ }
89
+
90
+ .jp-xtralab-DiffWidget-scrollbarTrack::-webkit-scrollbar-thumb {
91
+ border: 2px solid transparent;
92
+ border-radius: 999px;
93
+ background-color: color-mix(
94
+ in srgb,
95
+ var(--jp-ui-font-color1) 28%,
96
+ transparent
97
+ );
98
+ background-clip: content-box;
99
+ }
100
+
101
+ .jp-xtralab-DiffWidget-scrollbarTrack::-webkit-scrollbar-thumb:hover {
102
+ background-color: color-mix(
103
+ in srgb,
104
+ var(--jp-ui-font-color1) 45%,
105
+ transparent
106
+ );
107
+ }
108
+
109
+ @supports (-moz-appearance: none) {
110
+ body[data-jp-theme-light] .jp-xtralab-DiffWidget-scrollbarTrack {
111
+ scrollbar-width: thin;
112
+ scrollbar-color: color-mix(
113
+ in srgb,
114
+ var(--jp-ui-font-color1) 28%,
115
+ transparent
116
+ )
117
+ transparent;
118
+ }
119
+ }
120
+
52
121
  .jp-xtralab-DiffWidget-viewModeToolbarItem,
53
122
  .jp-xtralab-DiffWidget-diffStyleToolbarItem,
54
123
  .jp-xtralab-DiffWidget-lineWrapToolbarItem {