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.
- package/lib/git/diffScrollbar.d.ts +22 -0
- package/lib/git/diffScrollbar.js +82 -0
- package/lib/git/diffSurface.js +46 -21
- package/lib/git/diffTheme.d.ts +5 -0
- package/lib/git/diffTheme.js +10 -0
- package/lib/terminalNotifications/index.d.ts +2 -1
- package/lib/terminalNotifications/index.js +53 -13
- package/package.json +1 -1
- package/src/git/diffScrollbar.ts +103 -0
- package/src/git/diffSurface.tsx +34 -2
- package/src/git/diffTheme.ts +11 -0
- package/src/terminalNotifications/index.ts +68 -13
- package/style/git.css +69 -0
|
@@ -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
|
+
}
|
package/lib/git/diffSurface.js
CHANGED
|
@@ -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 {
|
|
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${
|
|
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" },
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
/**
|
package/lib/git/diffTheme.d.ts
CHANGED
|
@@ -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
|
*/
|
package/lib/git/diffTheme.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
94
|
+
showWebNotification(title, body, session);
|
|
47
95
|
}
|
|
48
96
|
else if (Notification.permission !== 'denied') {
|
|
49
|
-
|
|
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.
|
|
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,
|
|
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
|
+
"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
|
+
}
|
package/src/git/diffSurface.tsx
CHANGED
|
@@ -30,7 +30,8 @@ import {
|
|
|
30
30
|
type ImageDiffViewMode,
|
|
31
31
|
type NotebookDiffViewMode
|
|
32
32
|
} from './diffPreferences';
|
|
33
|
-
import {
|
|
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${
|
|
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
|
package/src/git/diffTheme.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
198
|
+
showWebNotification(title, body, session);
|
|
136
199
|
} else if (Notification.permission !== 'denied') {
|
|
137
|
-
|
|
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.
|
|
307
|
+
// reports a click.
|
|
249
308
|
const desktopBridge = (window as Window & { xtralab?: IDesktopBridge })
|
|
250
309
|
.xtralab;
|
|
251
|
-
desktopBridge?.onFocusTerminal?.(
|
|
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 {
|