xtralab 0.8.0 → 0.10.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 +30 -6
- package/lib/fileBrowser/commands.d.ts +7 -0
- package/lib/fileBrowser/commands.js +74 -1
- package/lib/fileBrowser/fileBrowser.js +4 -1
- package/lib/fileBrowser/icons.d.ts +5 -9
- package/lib/fileBrowser/icons.js +97 -13
- package/lib/fileBrowser/index.js +5 -3
- package/lib/fileTypeIcons/index.d.ts +6 -0
- package/lib/fileTypeIcons/index.js +144 -0
- package/lib/git/commands.d.ts +34 -33
- package/lib/git/commands.js +132 -10
- package/lib/git/diffModel.d.ts +14 -0
- package/lib/git/diffModel.js +75 -0
- package/lib/git/diffProvider.d.ts +0 -5
- package/lib/git/diffProvider.js +6 -376
- package/lib/git/diffSurface.d.ts +36 -26
- package/lib/git/diffSurface.js +13 -20
- package/lib/git/diffTheme.d.ts +9 -0
- package/lib/git/diffTheme.js +31 -0
- package/lib/git/diffWidget.d.ts +54 -91
- package/lib/git/diffWidget.js +184 -361
- package/lib/git/index.d.ts +1 -6
- package/lib/git/index.js +7 -23
- package/lib/git/notebookDiff.d.ts +6 -1
- package/lib/git/notebookDiff.js +4 -3
- package/lib/index.js +4 -0
- package/lib/terminalNotifications/index.d.ts +15 -0
- package/lib/terminalNotifications/index.js +213 -0
- package/lib/terminals/model.d.ts +1 -1
- package/lib/terminals/model.js +1 -1
- package/lib/terminals/widget.d.ts +1 -1
- package/lib/terminals/widget.js +1 -1
- package/package.json +3 -3
- package/schema/plugin.json +6 -0
- package/schema/terminal-notifications.json +21 -0
- package/src/fileBrowser/commands.ts +91 -2
- package/src/fileBrowser/fileBrowser.tsx +4 -1
- package/src/fileBrowser/icons.ts +103 -13
- package/src/fileBrowser/index.ts +9 -3
- package/src/fileTypeIcons/index.ts +174 -0
- package/src/git/commands.ts +177 -43
- package/src/git/diffModel.ts +102 -0
- package/src/git/diffProvider.tsx +8 -540
- package/src/git/diffSurface.tsx +44 -28
- package/src/git/diffTheme.ts +40 -0
- package/src/git/diffWidget.tsx +256 -448
- package/src/git/index.ts +14 -28
- package/src/git/notebookDiff.tsx +10 -3
- package/src/index.ts +4 -0
- package/src/terminalNotifications/index.ts +295 -0
- package/src/terminals/model.ts +1 -1
- package/src/terminals/widget.tsx +1 -1
- package/style/git.css +12 -9
- package/style/index.css +1 -0
- package/style/index.js +1 -0
- package/style/launcher.css +46 -18
- package/style/sidebar.css +14 -0
package/lib/git/index.d.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { JupyterFrontEndPlugin } from '@jupyterlab/application';
|
|
2
2
|
import { CommandArguments, CommandIDs } from './commands';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 1. {@link diffCommandPlugin} — the launcher's `xtralab:git:open-diff`.
|
|
7
|
-
* 2. `diffProviderPlugin` — registers the `@pierre/diffs` rendering as
|
|
8
|
-
* jupyterlab-git's diff providers (replacing its disabled
|
|
9
|
-
* notebook/text diff plugins).
|
|
4
|
+
* Plugins contributed by xtralab's git integration.
|
|
10
5
|
*/
|
|
11
6
|
declare const plugins: JupyterFrontEndPlugin<unknown>[];
|
|
12
7
|
export { CommandArguments, CommandIDs };
|
package/lib/git/index.js
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
import { IThemeManager, WidgetTracker } from '@jupyterlab/apputils';
|
|
2
2
|
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
|
|
3
|
-
import { CommandIDs, registerGitCommands } from './commands';
|
|
4
|
-
import { PREVIEW_DIFF_WIDGET_ID, pinnedDiffWidgetId } from './diffWidget';
|
|
3
|
+
import { CommandIDs, PREVIEW_DIFF_WIDGET_ID, pinnedDiffWidgetId, registerGitCommands } from './commands';
|
|
5
4
|
import diffProviderPlugin from './diffProvider';
|
|
6
5
|
const GIT_DIFF_COMMAND_PLUGIN_ID = 'xtralab:git-diff-command';
|
|
7
6
|
const GIT_DIFF_TRACKER_NAMESPACE = 'xtralab-git-diff';
|
|
8
7
|
/**
|
|
9
8
|
* The launcher's git diff command plugin.
|
|
10
9
|
*
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
* user sees in the sidebar. This plugin is the launcher dashboard's
|
|
14
|
-
* independent diff path: the `xtralab:git:open-diff` command the
|
|
15
|
-
* dashboard's "Changes" section calls, plus a tracker so reopening a diff
|
|
16
|
-
* reveals the existing tab and the preview/pin behavior keeps working.
|
|
10
|
+
* The upstream git panel stays enabled; this plugin only gives the launcher
|
|
11
|
+
* its own `xtralab:git:open-diff` command and preview/pinned-tab tracking.
|
|
17
12
|
*/
|
|
18
13
|
const diffCommandPlugin = {
|
|
19
14
|
id: GIT_DIFF_COMMAND_PLUGIN_ID,
|
|
@@ -21,9 +16,7 @@ const diffCommandPlugin = {
|
|
|
21
16
|
autoStart: true,
|
|
22
17
|
optional: [IThemeManager, IRenderMimeRegistry],
|
|
23
18
|
activate: (app, themeManager, rendermime) => {
|
|
24
|
-
// Track open
|
|
25
|
-
// already-open diff for the same file instead of creating a duplicate
|
|
26
|
-
// (and so a promoted preview can find its pinned twin).
|
|
19
|
+
// Track open diffs so repeated opens reveal the existing tab.
|
|
27
20
|
const tracker = new WidgetTracker({
|
|
28
21
|
namespace: GIT_DIFF_TRACKER_NAMESPACE
|
|
29
22
|
});
|
|
@@ -37,13 +30,9 @@ const diffCommandPlugin = {
|
|
|
37
30
|
themeManager,
|
|
38
31
|
contentsManager: app.serviceManager.contents,
|
|
39
32
|
rendermime,
|
|
40
|
-
// The launcher polls git status on its own cadence and the
|
|
41
|
-
// jupyterlab-git panel auto-refreshes on the contents `fileChanged`
|
|
42
|
-
// signal a hunk-discard save emits, so no extra refresh is needed.
|
|
43
|
-
onChanged: () => undefined,
|
|
44
33
|
trackDiff: widget => tracker.add(widget),
|
|
45
34
|
onPinned: current => {
|
|
46
|
-
const existing = findDiff(current.
|
|
35
|
+
const existing = findDiff(current.change, true);
|
|
47
36
|
if (existing !== undefined &&
|
|
48
37
|
existing !== current &&
|
|
49
38
|
!existing.isDisposed) {
|
|
@@ -51,7 +40,7 @@ const diffCommandPlugin = {
|
|
|
51
40
|
current.close();
|
|
52
41
|
return;
|
|
53
42
|
}
|
|
54
|
-
current.id = pinnedDiffWidgetId(current.
|
|
43
|
+
current.id = pinnedDiffWidgetId(current.change);
|
|
55
44
|
current.title.className = '';
|
|
56
45
|
app.shell.activateById(current.id);
|
|
57
46
|
},
|
|
@@ -60,12 +49,7 @@ const diffCommandPlugin = {
|
|
|
60
49
|
}
|
|
61
50
|
};
|
|
62
51
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* 1. {@link diffCommandPlugin} — the launcher's `xtralab:git:open-diff`.
|
|
66
|
-
* 2. `diffProviderPlugin` — registers the `@pierre/diffs` rendering as
|
|
67
|
-
* jupyterlab-git's diff providers (replacing its disabled
|
|
68
|
-
* notebook/text diff plugins).
|
|
52
|
+
* Plugins contributed by xtralab's git integration.
|
|
69
53
|
*/
|
|
70
54
|
const plugins = [
|
|
71
55
|
diffCommandPlugin,
|
|
@@ -97,7 +97,7 @@ export declare function parseNotebook(text: string): INotebook | null;
|
|
|
97
97
|
* nbformat allows multiline string fields (`source`, stream `text`,
|
|
98
98
|
* `text/plain` outputs, …) to be either a single string or an array of
|
|
99
99
|
* strings. Normalize to one string so equality checks and diffs see the
|
|
100
|
-
* same shape regardless of how the writer chose to
|
|
100
|
+
* same shape regardless of how the writer chose to serialize it.
|
|
101
101
|
*/
|
|
102
102
|
export declare function joinMultiline(value: string | string[] | undefined): string;
|
|
103
103
|
export declare function cellSource(cell: INotebookCell): string;
|
|
@@ -160,6 +160,11 @@ export declare function buildNotebookDiff(options: IBuildNotebookDiffOptions): I
|
|
|
160
160
|
interface INotebookDiffViewProps {
|
|
161
161
|
diff: INotebookDiffResult;
|
|
162
162
|
dark: boolean;
|
|
163
|
+
/**
|
|
164
|
+
* Whether a Pierre JupyterLab theme is active; selects Pierre's rich
|
|
165
|
+
* highlighting vs the CSS-variable theme (see {@link resolveDiffTheme}).
|
|
166
|
+
*/
|
|
167
|
+
pierreTheme: boolean;
|
|
163
168
|
rendermime: IRenderMimeRegistry | null;
|
|
164
169
|
}
|
|
165
170
|
/**
|
package/lib/git/notebookDiff.js
CHANGED
|
@@ -5,6 +5,7 @@ import { MessageLoop } from '@lumino/messaging';
|
|
|
5
5
|
import { Widget } from '@lumino/widgets';
|
|
6
6
|
import { FileDiff } from '@pierre/diffs/react';
|
|
7
7
|
import { parseDiffFromFile } from '@pierre/diffs';
|
|
8
|
+
import { resolveDiffTheme } from './diffTheme';
|
|
8
9
|
/**
|
|
9
10
|
* The CSS class added to the root of the notebook diff. Selectors that
|
|
10
11
|
* style cell sections, headers, and the per-cell diff blocks hang off this
|
|
@@ -50,7 +51,7 @@ function emptyNotebook() {
|
|
|
50
51
|
* nbformat allows multiline string fields (`source`, stream `text`,
|
|
51
52
|
* `text/plain` outputs, …) to be either a single string or an array of
|
|
52
53
|
* strings. Normalize to one string so equality checks and diffs see the
|
|
53
|
-
* same shape regardless of how the writer chose to
|
|
54
|
+
* same shape regardless of how the writer chose to serialize it.
|
|
54
55
|
*/
|
|
55
56
|
export function joinMultiline(value) {
|
|
56
57
|
if (value === undefined) {
|
|
@@ -596,8 +597,8 @@ function MarkdownPreviewSection(props) {
|
|
|
596
597
|
* the orientation clear without a separate column header.
|
|
597
598
|
*/
|
|
598
599
|
export function NotebookDiffView(props) {
|
|
599
|
-
const { diff, dark, rendermime } = props;
|
|
600
|
-
const theme = dark
|
|
600
|
+
const { diff, dark, pierreTheme, rendermime } = props;
|
|
601
|
+
const theme = resolveDiffTheme(dark, pierreTheme);
|
|
601
602
|
const summary = React.useMemo(() => summarize(diff.cells), [diff.cells]);
|
|
602
603
|
return (React.createElement("div", { className: NOTEBOOK_DIFF_CSS_CLASS },
|
|
603
604
|
React.createElement(NotebookDiffHeader, { summary: summary }),
|
package/lib/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import agentSessionsPlugin from './agentSessions';
|
|
2
2
|
import fileBrowserPlugin from './fileBrowser';
|
|
3
|
+
import fileTypeIconsPlugin from './fileTypeIcons';
|
|
3
4
|
import editorBreadcrumbsPlugin from './editorBreadcrumbs';
|
|
4
5
|
import editorIndentPlugin from './editorIndent';
|
|
5
6
|
import gitPlugins from './git';
|
|
6
7
|
import launcherPlugins from './launcher';
|
|
7
8
|
import menusPlugin from './menus';
|
|
8
9
|
import sidebarPlugin from './sidebar';
|
|
10
|
+
import terminalNotificationsPlugin from './terminalNotifications';
|
|
9
11
|
import terminalsPlugin from './terminals';
|
|
10
12
|
import topBarPlugin from './topBar';
|
|
11
13
|
/**
|
|
@@ -22,10 +24,12 @@ const plugins = [
|
|
|
22
24
|
editorBreadcrumbsPlugin,
|
|
23
25
|
editorIndentPlugin,
|
|
24
26
|
fileBrowserPlugin,
|
|
27
|
+
fileTypeIconsPlugin,
|
|
25
28
|
...gitPlugins,
|
|
26
29
|
...launcherPlugins,
|
|
27
30
|
menusPlugin,
|
|
28
31
|
sidebarPlugin,
|
|
32
|
+
terminalNotificationsPlugin,
|
|
29
33
|
terminalsPlugin,
|
|
30
34
|
topBarPlugin
|
|
31
35
|
];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { JupyterFrontEndPlugin } from '@jupyterlab/application';
|
|
2
|
+
/**
|
|
3
|
+
* Turns the notifications coding agents already emit (OSC 9, OSC 777, the bell)
|
|
4
|
+
* into desktop notifications. JupyterLab's xterm renders these sequences but
|
|
5
|
+
* never forwards them to the OS, so this plugin hooks each terminal and bridges
|
|
6
|
+
* them through `window.xtralab.notify` (desktop) or the web Notifications API.
|
|
7
|
+
*
|
|
8
|
+
* A notification is suppressed while its terminal is the focused, active tab,
|
|
9
|
+
* and throttled per terminal. The desktop shell advertises
|
|
10
|
+
* `TERM_PROGRAM=iTerm.app` so agents emit OSC 9 in the first place, and the
|
|
11
|
+
* session name is forwarded so clicking a notification focuses the terminal
|
|
12
|
+
* that fired it.
|
|
13
|
+
*/
|
|
14
|
+
declare const plugin: JupyterFrontEndPlugin<void>;
|
|
15
|
+
export default plugin;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
2
|
+
import { ITerminalTracker } from '@jupyterlab/terminal';
|
|
3
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
4
|
+
const PLUGIN_ID = 'xtralab:terminal-notifications';
|
|
5
|
+
// Per-terminal gap between notifications, so the OSC-9-plus-bell agents emit
|
|
6
|
+
// together collapses into one and a chatty program cannot flood the OS.
|
|
7
|
+
const NOTIFY_THROTTLE_MS = 3000;
|
|
8
|
+
// iTerm2 growl notification: `ESC ] 9 ; <message> BEL`. What Claude Code and
|
|
9
|
+
// most agents emit on their default `auto` setting once TERM_PROGRAM is set.
|
|
10
|
+
const OSC_ITERM2_GROWL = 9;
|
|
11
|
+
// rxvt/Ghostty notification: `ESC ] 777 ; notify ; <title> ; <body> BEL`.
|
|
12
|
+
const OSC_RXVT_NOTIFY = 777;
|
|
13
|
+
const MAX_TEXT_LENGTH = 256;
|
|
14
|
+
/**
|
|
15
|
+
* Turns the notifications coding agents already emit (OSC 9, OSC 777, the bell)
|
|
16
|
+
* into desktop notifications. JupyterLab's xterm renders these sequences but
|
|
17
|
+
* never forwards them to the OS, so this plugin hooks each terminal and bridges
|
|
18
|
+
* them through `window.xtralab.notify` (desktop) or the web Notifications API.
|
|
19
|
+
*
|
|
20
|
+
* A notification is suppressed while its terminal is the focused, active tab,
|
|
21
|
+
* and throttled per terminal. The desktop shell advertises
|
|
22
|
+
* `TERM_PROGRAM=iTerm.app` so agents emit OSC 9 in the first place, and the
|
|
23
|
+
* session name is forwarded so clicking a notification focuses the terminal
|
|
24
|
+
* that fired it.
|
|
25
|
+
*/
|
|
26
|
+
const plugin = {
|
|
27
|
+
id: PLUGIN_ID,
|
|
28
|
+
description: 'Forwards desktop notifications emitted by agents in xtralab terminals (OSC 9, OSC 777, bell) to the operating system.',
|
|
29
|
+
autoStart: true,
|
|
30
|
+
requires: [ITerminalTracker],
|
|
31
|
+
optional: [ISettingRegistry, ITranslator],
|
|
32
|
+
activate: async (app, tracker, settingRegistry, translator) => {
|
|
33
|
+
var _a;
|
|
34
|
+
const trans = (translator !== null && translator !== void 0 ? translator : nullTranslator).load('jupyterlab');
|
|
35
|
+
let enabled = true;
|
|
36
|
+
let notifyOnBell = true;
|
|
37
|
+
// Per-widget throttle timestamps and xterm hooks. WeakMaps let disposed
|
|
38
|
+
// widgets be collected; hooks are torn down when a tab closes.
|
|
39
|
+
const lastNotified = new WeakMap();
|
|
40
|
+
const hooks = new WeakMap();
|
|
41
|
+
// True when the user is already looking at this terminal, so a banner would
|
|
42
|
+
// just be noise.
|
|
43
|
+
const isActivelyViewing = (widget) => document.hasFocus() && app.shell.currentWidget === widget;
|
|
44
|
+
const deliver = (title, body, session) => {
|
|
45
|
+
const bridge = window.xtralab;
|
|
46
|
+
if (bridge && typeof bridge.notify === 'function') {
|
|
47
|
+
void Promise.resolve(bridge.notify(title, body, session)).catch(reason => {
|
|
48
|
+
console.warn('xtralab: desktop notification failed', reason);
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Plain-browser fallback (pip install): the Web Notifications API.
|
|
53
|
+
if (typeof Notification === 'undefined') {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (Notification.permission === 'granted') {
|
|
57
|
+
new Notification(title, { body });
|
|
58
|
+
}
|
|
59
|
+
else if (Notification.permission !== 'denied') {
|
|
60
|
+
void Notification.requestPermission().then(permission => {
|
|
61
|
+
if (permission === 'granted') {
|
|
62
|
+
new Notification(title, { body });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const notify = (widget, title, body) => {
|
|
68
|
+
var _a;
|
|
69
|
+
if (!enabled || isActivelyViewing(widget)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const previous = lastNotified.get(widget);
|
|
74
|
+
if (previous !== undefined && now - previous < NOTIFY_THROTTLE_MS) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
lastNotified.set(widget, now);
|
|
78
|
+
const session = (_a = widget.content.session) === null || _a === void 0 ? void 0 : _a.name;
|
|
79
|
+
deliver(sanitize(title) || 'xtralab', sanitize(body), session);
|
|
80
|
+
};
|
|
81
|
+
// The tab label is the agent's name once the launcher or an xterm title sets
|
|
82
|
+
// it, so it reads well as the title for body-only sequences (OSC 9, bell).
|
|
83
|
+
const labelOf = (widget) => widget.title.label || trans.__('Terminal');
|
|
84
|
+
const onOsc9 = (widget, data) => {
|
|
85
|
+
// OSC 9 is overloaded: ConEmu/Windows Terminal use `9 ; <n> ; …`
|
|
86
|
+
// subcommands (e.g. `9 ; 4` progress), so skip a numeric-subcommand
|
|
87
|
+
// payload and leave it unconsumed rather than treat it as a notification.
|
|
88
|
+
if (/^\d+(;|$)/.test(data)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const message = sanitize(data);
|
|
92
|
+
if (message) {
|
|
93
|
+
notify(widget, labelOf(widget), message);
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
};
|
|
97
|
+
const onOsc777 = (widget, data) => {
|
|
98
|
+
var _a;
|
|
99
|
+
// `notify ; <title> ; <body>`; a body may contain ';'. Other OSC 777
|
|
100
|
+
// subcommands are left unconsumed.
|
|
101
|
+
const parts = data.split(';');
|
|
102
|
+
if (parts.shift() !== 'notify') {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const title = sanitize((_a = parts.shift()) !== null && _a !== void 0 ? _a : '') || labelOf(widget);
|
|
106
|
+
const body = sanitize(parts.join(';'));
|
|
107
|
+
if (body || title) {
|
|
108
|
+
notify(widget, title, body);
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
};
|
|
112
|
+
const onBell = (widget) => {
|
|
113
|
+
if (notifyOnBell) {
|
|
114
|
+
notify(widget, labelOf(widget), trans.__('Activity in terminal'));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const hookWidget = (widget) => {
|
|
118
|
+
if (hooks.has(widget)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const content = widget.content;
|
|
122
|
+
void content.ready
|
|
123
|
+
.then(() => {
|
|
124
|
+
const term = content._term;
|
|
125
|
+
if (widget.isDisposed) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!term) {
|
|
129
|
+
console.warn('xtralab: xterm internals not found; terminal notifications are disabled for this terminal');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
// Handlers are wrapped so a parse slip cannot break xterm's parser; an
|
|
133
|
+
// OSC handler returns whether it consumed the sequence.
|
|
134
|
+
const disposables = [
|
|
135
|
+
term.onBell(() => guard(() => onBell(widget))),
|
|
136
|
+
term.parser.registerOscHandler(OSC_ITERM2_GROWL, data => guardOsc(() => onOsc9(widget, data))),
|
|
137
|
+
term.parser.registerOscHandler(OSC_RXVT_NOTIFY, data => guardOsc(() => onOsc777(widget, data)))
|
|
138
|
+
];
|
|
139
|
+
hooks.set(widget, disposables);
|
|
140
|
+
widget.disposed.connect(() => {
|
|
141
|
+
for (const disposable of disposables) {
|
|
142
|
+
disposable.dispose();
|
|
143
|
+
}
|
|
144
|
+
hooks.delete(widget);
|
|
145
|
+
});
|
|
146
|
+
})
|
|
147
|
+
.catch(reason => {
|
|
148
|
+
console.warn('xtralab: could not hook terminal notifications', reason);
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
tracker.forEach(hookWidget);
|
|
152
|
+
tracker.widgetAdded.connect((_, widget) => hookWidget(widget));
|
|
153
|
+
// Focus the terminal that fired a notification when the desktop shell
|
|
154
|
+
// reports a click. `terminal:open` activates its tab or reopens the session.
|
|
155
|
+
const desktopBridge = window
|
|
156
|
+
.xtralab;
|
|
157
|
+
(_a = desktopBridge === null || desktopBridge === void 0 ? void 0 : desktopBridge.onFocusTerminal) === null || _a === void 0 ? void 0 : _a.call(desktopBridge, session => {
|
|
158
|
+
if (session) {
|
|
159
|
+
void app.commands.execute('terminal:open', { name: session });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
if (settingRegistry) {
|
|
163
|
+
try {
|
|
164
|
+
const settings = await settingRegistry.load(PLUGIN_ID);
|
|
165
|
+
const readSettings = () => {
|
|
166
|
+
enabled = boolOption(settings.composite.enabled, true);
|
|
167
|
+
notifyOnBell = boolOption(settings.composite.notifyOnBell, true);
|
|
168
|
+
};
|
|
169
|
+
readSettings();
|
|
170
|
+
settings.changed.connect(readSettings);
|
|
171
|
+
}
|
|
172
|
+
catch (reason) {
|
|
173
|
+
console.error('xtralab: failed to load terminal-notifications settings', reason);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
// Run `fn`, swallowing errors so a malformed escape sequence cannot take the
|
|
179
|
+
// terminal's parser or bell handler down with it.
|
|
180
|
+
function guard(fn) {
|
|
181
|
+
try {
|
|
182
|
+
fn();
|
|
183
|
+
}
|
|
184
|
+
catch (reason) {
|
|
185
|
+
console.warn('xtralab: terminal notification handler failed', reason);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// `guard` for an OSC handler that reports whether it consumed the sequence.
|
|
189
|
+
function guardOsc(fn) {
|
|
190
|
+
try {
|
|
191
|
+
return fn();
|
|
192
|
+
}
|
|
193
|
+
catch (reason) {
|
|
194
|
+
console.warn('xtralab: terminal notification handler failed', reason);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Replace control characters with spaces (so a notification cannot carry escape
|
|
199
|
+
// sequences), collapse whitespace, and clamp the length.
|
|
200
|
+
function sanitize(value) {
|
|
201
|
+
var _a;
|
|
202
|
+
let result = '';
|
|
203
|
+
for (const char of value) {
|
|
204
|
+
const code = (_a = char.codePointAt(0)) !== null && _a !== void 0 ? _a : 0;
|
|
205
|
+
const isControl = code < 0x20 || (code >= 0x7f && code <= 0x9f);
|
|
206
|
+
result += isControl ? ' ' : char;
|
|
207
|
+
}
|
|
208
|
+
return result.replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT_LENGTH);
|
|
209
|
+
}
|
|
210
|
+
function boolOption(value, fallback) {
|
|
211
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
212
|
+
}
|
|
213
|
+
export default plugin;
|
package/lib/terminals/model.d.ts
CHANGED
|
@@ -82,7 +82,7 @@ export declare class SessionRegistry implements IDisposable {
|
|
|
82
82
|
agentCommandFor(name: string): string | null;
|
|
83
83
|
/**
|
|
84
84
|
* Return the open widget for a session, if any. The panel uses this to
|
|
85
|
-
* switch
|
|
85
|
+
* switch behavior between "activate existing tab" and "open a new tab
|
|
86
86
|
* connected to the live session".
|
|
87
87
|
*/
|
|
88
88
|
widgetFor(name: string): TerminalWidget | null;
|
package/lib/terminals/model.js
CHANGED
|
@@ -176,7 +176,7 @@ export class SessionRegistry {
|
|
|
176
176
|
}
|
|
177
177
|
/**
|
|
178
178
|
* Return the open widget for a session, if any. The panel uses this to
|
|
179
|
-
* switch
|
|
179
|
+
* switch behavior between "activate existing tab" and "open a new tab
|
|
180
180
|
* connected to the live session".
|
|
181
181
|
*/
|
|
182
182
|
widgetFor(name) {
|
|
@@ -18,7 +18,7 @@ export declare const RUNNING_TERMINALS_ID = "xtralab-running-terminals";
|
|
|
18
18
|
* Backed by {@link SessionRegistry}: a `UseSignal` re-renders the list
|
|
19
19
|
* whenever the registry emits `stateChanged`, so it tracks
|
|
20
20
|
* `runningChanged` and the agent-title cache without any local state.
|
|
21
|
-
* Click
|
|
21
|
+
* Click behavior (activate vs. reopen, shutdown, new terminal) is
|
|
22
22
|
* delegated to plugin-supplied callbacks so the widget never imports
|
|
23
23
|
* `app`.
|
|
24
24
|
*/
|
package/lib/terminals/widget.js
CHANGED
|
@@ -17,7 +17,7 @@ export const RUNNING_TERMINALS_ID = 'xtralab-running-terminals';
|
|
|
17
17
|
* Backed by {@link SessionRegistry}: a `UseSignal` re-renders the list
|
|
18
18
|
* whenever the registry emits `stateChanged`, so it tracks
|
|
19
19
|
* `runningChanged` and the agent-title cache without any local state.
|
|
20
|
-
* Click
|
|
20
|
+
* Click behavior (activate vs. reopen, shutdown, new terminal) is
|
|
21
21
|
* delegated to plugin-supplied callbacks so the widget never imports
|
|
22
22
|
* `app`.
|
|
23
23
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xtralab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "An opinionated JupyterLab meta-package that bundles a curated set of extensions, ships a path-first file browser, renders jupyterlab-git's text, notebook and image diffs with its own viewers, and applies a quieter default workspace configuration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -87,8 +87,8 @@
|
|
|
87
87
|
"@lumino/polling": "^2.1.5",
|
|
88
88
|
"@lumino/signaling": "^2.0.0",
|
|
89
89
|
"@lumino/widgets": "^2.0.0",
|
|
90
|
-
"@pierre/diffs": "^1.
|
|
91
|
-
"@pierre/trees": "1.0.0-beta.
|
|
90
|
+
"@pierre/diffs": "^1.2.7",
|
|
91
|
+
"@pierre/trees": "1.0.0-beta.4",
|
|
92
92
|
"detect-indent": "^7.0.2",
|
|
93
93
|
"ignore": "^5.3.2",
|
|
94
94
|
"react": "^18.2.0",
|
package/schema/plugin.json
CHANGED
|
@@ -58,6 +58,12 @@
|
|
|
58
58
|
"command": "xtralab:download",
|
|
59
59
|
"selector": ".jp-xtralab-FileBrowser [data-type=\"item\"][data-item-type=\"file\"]",
|
|
60
60
|
"rank": 10
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"command": "xtralab:reveal-in-file-tree",
|
|
64
|
+
"args": { "fromTab": true },
|
|
65
|
+
"selector": "[data-type=\"document-title\"]",
|
|
66
|
+
"rank": 25
|
|
61
67
|
}
|
|
62
68
|
]
|
|
63
69
|
},
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "xtralab terminal notifications",
|
|
3
|
+
"description": "Desktop notifications from coding agents running in xtralab terminals.",
|
|
4
|
+
"jupyter.lab.shortcuts": [],
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"enabled": {
|
|
8
|
+
"type": "boolean",
|
|
9
|
+
"title": "Enable terminal notifications",
|
|
10
|
+
"description": "When true, a desktop notification is shown when a program in a terminal asks for one (the iTerm2/Ghostty OSC sequences or the bell) — for example when a coding agent finishes or needs input. Notifications are suppressed while you are looking at that terminal.",
|
|
11
|
+
"default": true
|
|
12
|
+
},
|
|
13
|
+
"notifyOnBell": {
|
|
14
|
+
"type": "boolean",
|
|
15
|
+
"title": "Notify on the terminal bell",
|
|
16
|
+
"description": "When true, a plain terminal bell also raises a notification. The richer OSC 9 / OSC 777 sequences are always handled; disable this if the bell alone proves too noisy.",
|
|
17
|
+
"default": true
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"additionalProperties": false
|
|
21
|
+
}
|
|
@@ -2,11 +2,14 @@ import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
|
2
2
|
import {
|
|
3
3
|
Clipboard,
|
|
4
4
|
Dialog,
|
|
5
|
+
ICommandPalette,
|
|
5
6
|
InputDialog,
|
|
6
7
|
showDialog,
|
|
7
8
|
showErrorMessage
|
|
8
9
|
} from '@jupyterlab/apputils';
|
|
9
10
|
import { PageConfig, PathExt } from '@jupyterlab/coreutils';
|
|
11
|
+
import { IDocumentManager } from '@jupyterlab/docmanager';
|
|
12
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
10
13
|
import {
|
|
11
14
|
addIcon,
|
|
12
15
|
closeIcon,
|
|
@@ -22,7 +25,7 @@ import {
|
|
|
22
25
|
refreshIcon
|
|
23
26
|
} from '@jupyterlab/ui-components';
|
|
24
27
|
import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
|
|
25
|
-
import { ContextMenu } from '@lumino/widgets';
|
|
28
|
+
import { ContextMenu, Widget } from '@lumino/widgets';
|
|
26
29
|
|
|
27
30
|
import { toCanonicalPath, toServerPath } from './contents';
|
|
28
31
|
import { FILE_BROWSER_ID, IXtralabFileBrowser } from './widget';
|
|
@@ -47,6 +50,7 @@ export namespace CommandIDs {
|
|
|
47
50
|
export const createNewDirectory = 'xtralab:create-new-directory';
|
|
48
51
|
export const newLauncher = 'xtralab:new-launcher';
|
|
49
52
|
export const revealPath = 'xtralab:reveal-path';
|
|
53
|
+
export const revealInFileTree = 'xtralab:reveal-in-file-tree';
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
/**
|
|
@@ -60,6 +64,9 @@ export const OPEN_WITH_SUBMENU_ID = 'jp-contextmenu-xtralab-open-with';
|
|
|
60
64
|
export interface IRegisterCommandsOptions {
|
|
61
65
|
app: JupyterFrontEnd;
|
|
62
66
|
browser: IXtralabFileBrowser;
|
|
67
|
+
docManager: IDocumentManager;
|
|
68
|
+
palette: ICommandPalette | null;
|
|
69
|
+
translator: ITranslator | null;
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
/**
|
|
@@ -152,6 +159,53 @@ function getWorkingDirectory(browser: IXtralabFileBrowser): string {
|
|
|
152
159
|
return PathExt.dirname(serverPath);
|
|
153
160
|
}
|
|
154
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the main-area widget whose tab is the target of the current
|
|
164
|
+
* context-menu event, or `null` when that target is not a document tab. The
|
|
165
|
+
* shell stamps each tab's `data-id` with its widget id, so the right-clicked
|
|
166
|
+
* tab resolves even when it is not the current one.
|
|
167
|
+
*
|
|
168
|
+
* Valid only during a context-menu invocation: `contextMenuHitTest` reads the
|
|
169
|
+
* last context-menu event, which is not cleared when the menu closes, so
|
|
170
|
+
* {@link documentPathToReveal} gates this to avoid resolving a stale tab.
|
|
171
|
+
*/
|
|
172
|
+
function contextMenuTabWidget(app: JupyterFrontEnd): Widget | null {
|
|
173
|
+
const node = app.contextMenuHitTest(
|
|
174
|
+
n => n.dataset !== undefined && n.dataset.type === 'document-title'
|
|
175
|
+
);
|
|
176
|
+
const id = node?.dataset.id;
|
|
177
|
+
if (id === undefined) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
for (const widget of app.shell.widgets('main')) {
|
|
181
|
+
if (widget.id === id) {
|
|
182
|
+
return widget;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve the document to reveal as a canonical `@pierre/trees` path: the
|
|
190
|
+
* right-clicked tab when `fromContextMenu` is true (the file-tab menu),
|
|
191
|
+
* otherwise the active main-area widget (the palette). A file's context
|
|
192
|
+
* `path` is already canonical. Returns `undefined` when nothing resolves.
|
|
193
|
+
*/
|
|
194
|
+
function documentPathToReveal(
|
|
195
|
+
app: JupyterFrontEnd,
|
|
196
|
+
docManager: IDocumentManager,
|
|
197
|
+
fromContextMenu: boolean
|
|
198
|
+
): string | undefined {
|
|
199
|
+
const widget =
|
|
200
|
+
(fromContextMenu ? contextMenuTabWidget(app) : null) ??
|
|
201
|
+
app.shell.currentWidget;
|
|
202
|
+
if (widget === null) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const path = docManager.contextForWidget(widget)?.path ?? '';
|
|
206
|
+
return path.length > 0 ? path : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
155
209
|
/**
|
|
156
210
|
* Refresh the "Open With" submenu's items from the document factories that
|
|
157
211
|
* can open the file under the right-click. Mirrors the
|
|
@@ -210,8 +264,9 @@ function makeOpenWithUpdater(
|
|
|
210
264
|
* the plugin.
|
|
211
265
|
*/
|
|
212
266
|
export function registerCommands(opts: IRegisterCommandsOptions): () => void {
|
|
213
|
-
const { app, browser } = opts;
|
|
267
|
+
const { app, browser, docManager, palette, translator } = opts;
|
|
214
268
|
const { commands } = app;
|
|
269
|
+
const trans = (translator ?? nullTranslator).load('jupyterlab');
|
|
215
270
|
|
|
216
271
|
commands.addCommand(CommandIDs.open, {
|
|
217
272
|
label: args =>
|
|
@@ -439,6 +494,23 @@ export function registerCommands(opts: IRegisterCommandsOptions): () => void {
|
|
|
439
494
|
}
|
|
440
495
|
});
|
|
441
496
|
|
|
497
|
+
// Reveal the resolved document in the tree browser via the reveal-path
|
|
498
|
+
// command, which surfaces the sidebar and scrolls to the target.
|
|
499
|
+
commands.addCommand(CommandIDs.revealInFileTree, {
|
|
500
|
+
label: trans.__('Show in File Tree'),
|
|
501
|
+
caption: trans.__('Show this file in the xtralab file browser'),
|
|
502
|
+
isEnabled: args =>
|
|
503
|
+
documentPathToReveal(app, docManager, args.fromTab === true) !==
|
|
504
|
+
undefined,
|
|
505
|
+
execute: args => {
|
|
506
|
+
const path = documentPathToReveal(app, docManager, args.fromTab === true);
|
|
507
|
+
if (path === undefined) {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
return commands.execute(CommandIDs.revealPath, { path });
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
442
514
|
commands.addCommand(CommandIDs.createNewDirectory, {
|
|
443
515
|
label: 'New Folder',
|
|
444
516
|
caption: 'Create a new folder',
|
|
@@ -479,6 +551,21 @@ export function registerCommands(opts: IRegisterCommandsOptions): () => void {
|
|
|
479
551
|
}
|
|
480
552
|
});
|
|
481
553
|
|
|
554
|
+
// This command's enabled state tracks the active widget, not the tree
|
|
555
|
+
// selection, so re-evaluate it on current-widget changes. `currentChanged`
|
|
556
|
+
// is optional on the shell interface.
|
|
557
|
+
const onCurrentChanged = (): void => {
|
|
558
|
+
commands.notifyCommandChanged(CommandIDs.revealInFileTree);
|
|
559
|
+
};
|
|
560
|
+
app.shell.currentChanged?.connect(onCurrentChanged);
|
|
561
|
+
|
|
562
|
+
// Also surface it in the palette (acts on the active editor); the file-tab
|
|
563
|
+
// context-menu entry is declared in `schema/plugin.json`.
|
|
564
|
+
const paletteItem = palette?.addItem({
|
|
565
|
+
command: CommandIDs.revealInFileTree,
|
|
566
|
+
category: trans.__('File Browser')
|
|
567
|
+
});
|
|
568
|
+
|
|
482
569
|
// The static items are declared in `schema/plugin.json` under
|
|
483
570
|
// `jupyter.lab.menus.context` so users can override or disable them. The
|
|
484
571
|
// schema also declares an empty submenu placeholder with id
|
|
@@ -489,6 +576,8 @@ export function registerCommands(opts: IRegisterCommandsOptions): () => void {
|
|
|
489
576
|
|
|
490
577
|
return () => {
|
|
491
578
|
app.contextMenu.opened.disconnect(updateOpenWithMenu);
|
|
579
|
+
app.shell.currentChanged?.disconnect(onCurrentChanged);
|
|
580
|
+
paletteItem?.dispose();
|
|
492
581
|
};
|
|
493
582
|
}
|
|
494
583
|
|
|
@@ -652,7 +652,10 @@ export function FileBrowserComponent(
|
|
|
652
652
|
previous?.deselect();
|
|
653
653
|
}
|
|
654
654
|
target.select();
|
|
655
|
-
|
|
655
|
+
// `select` highlights the row; `scrollToPath` focuses it and scrolls it
|
|
656
|
+
// to the middle of the viewport, even when the row is virtualized out of
|
|
657
|
+
// the rendered window.
|
|
658
|
+
model.scrollToPath(canonicalPath, { focus: true, offset: 'center' });
|
|
656
659
|
};
|
|
657
660
|
|
|
658
661
|
/**
|