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.
Files changed (57) hide show
  1. package/README.md +30 -6
  2. package/lib/fileBrowser/commands.d.ts +7 -0
  3. package/lib/fileBrowser/commands.js +74 -1
  4. package/lib/fileBrowser/fileBrowser.js +4 -1
  5. package/lib/fileBrowser/icons.d.ts +5 -9
  6. package/lib/fileBrowser/icons.js +97 -13
  7. package/lib/fileBrowser/index.js +5 -3
  8. package/lib/fileTypeIcons/index.d.ts +6 -0
  9. package/lib/fileTypeIcons/index.js +144 -0
  10. package/lib/git/commands.d.ts +34 -33
  11. package/lib/git/commands.js +132 -10
  12. package/lib/git/diffModel.d.ts +14 -0
  13. package/lib/git/diffModel.js +75 -0
  14. package/lib/git/diffProvider.d.ts +0 -5
  15. package/lib/git/diffProvider.js +6 -376
  16. package/lib/git/diffSurface.d.ts +36 -26
  17. package/lib/git/diffSurface.js +13 -20
  18. package/lib/git/diffTheme.d.ts +9 -0
  19. package/lib/git/diffTheme.js +31 -0
  20. package/lib/git/diffWidget.d.ts +54 -91
  21. package/lib/git/diffWidget.js +184 -361
  22. package/lib/git/index.d.ts +1 -6
  23. package/lib/git/index.js +7 -23
  24. package/lib/git/notebookDiff.d.ts +6 -1
  25. package/lib/git/notebookDiff.js +4 -3
  26. package/lib/index.js +4 -0
  27. package/lib/terminalNotifications/index.d.ts +15 -0
  28. package/lib/terminalNotifications/index.js +213 -0
  29. package/lib/terminals/model.d.ts +1 -1
  30. package/lib/terminals/model.js +1 -1
  31. package/lib/terminals/widget.d.ts +1 -1
  32. package/lib/terminals/widget.js +1 -1
  33. package/package.json +3 -3
  34. package/schema/plugin.json +6 -0
  35. package/schema/terminal-notifications.json +21 -0
  36. package/src/fileBrowser/commands.ts +91 -2
  37. package/src/fileBrowser/fileBrowser.tsx +4 -1
  38. package/src/fileBrowser/icons.ts +103 -13
  39. package/src/fileBrowser/index.ts +9 -3
  40. package/src/fileTypeIcons/index.ts +174 -0
  41. package/src/git/commands.ts +177 -43
  42. package/src/git/diffModel.ts +102 -0
  43. package/src/git/diffProvider.tsx +8 -540
  44. package/src/git/diffSurface.tsx +44 -28
  45. package/src/git/diffTheme.ts +40 -0
  46. package/src/git/diffWidget.tsx +256 -448
  47. package/src/git/index.ts +14 -28
  48. package/src/git/notebookDiff.tsx +10 -3
  49. package/src/index.ts +4 -0
  50. package/src/terminalNotifications/index.ts +295 -0
  51. package/src/terminals/model.ts +1 -1
  52. package/src/terminals/widget.tsx +1 -1
  53. package/style/git.css +12 -9
  54. package/style/index.css +1 -0
  55. package/style/index.js +1 -0
  56. package/style/launcher.css +46 -18
  57. package/style/sidebar.css +14 -0
@@ -1,12 +1,7 @@
1
1
  import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
2
  import { CommandArguments, CommandIDs } from './commands';
3
3
  /**
4
- * Every plugin contributed by xtralab's git integration:
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 `@jupyterlab/git` frontend stays enabled (only its diff plugins are
12
- * swapped — see `diffProvider.tsx`), so the upstream git panel is what the
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 diff widgets so the openDiff handler can reveal an
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.content.change, true);
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.content.change);
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
- * Every plugin contributed by xtralab's git integration:
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 serialise it.
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
  /**
@@ -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 serialise it.
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 ? 'pierre-dark' : 'pierre-light';
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;
@@ -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 behaviour between "activate existing tab" and "open a new tab
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;
@@ -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 behaviour between "activate existing tab" and "open a new tab
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 behaviour (activate vs. reopen, shutdown, new terminal) is
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
  */
@@ -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 behaviour (activate vs. reopen, shutdown, new terminal) is
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.8.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.1.20",
91
- "@pierre/trees": "1.0.0-beta.3",
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",
@@ -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
- model.focusPath(canonicalPath);
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
  /**