xtralab 0.1.1 → 0.2.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 (65) hide show
  1. package/README.md +86 -0
  2. package/lib/fileBrowser/fileBrowser.js +71 -1
  3. package/lib/fileBrowser/gitignore.d.ts +29 -0
  4. package/lib/fileBrowser/gitignore.js +74 -0
  5. package/lib/fileBrowser/index.js +1 -1
  6. package/lib/git/api.d.ts +11 -0
  7. package/lib/git/api.js +19 -0
  8. package/lib/git/commands.d.ts +3 -1
  9. package/lib/git/commands.js +10 -4
  10. package/lib/git/diffWidget.d.ts +11 -0
  11. package/lib/git/diffWidget.js +90 -9
  12. package/lib/git/index.js +23 -7
  13. package/lib/git/panelComponent.d.ts +3 -1
  14. package/lib/git/panelComponent.js +6 -3
  15. package/lib/index.js +9 -1
  16. package/lib/launcher/agents.d.ts +78 -0
  17. package/lib/launcher/agents.js +168 -0
  18. package/lib/launcher/availability.d.ts +12 -0
  19. package/lib/launcher/availability.js +53 -0
  20. package/lib/launcher/commands.d.ts +22 -0
  21. package/lib/launcher/commands.js +89 -0
  22. package/lib/launcher/dashboard.d.ts +46 -0
  23. package/lib/launcher/dashboard.js +164 -0
  24. package/lib/launcher/icons.d.ts +33 -0
  25. package/lib/launcher/icons.js +113 -0
  26. package/lib/launcher/index.d.ts +23 -0
  27. package/lib/launcher/index.js +191 -0
  28. package/lib/launcher/invocation.d.ts +29 -0
  29. package/lib/launcher/invocation.js +42 -0
  30. package/lib/menus/index.d.ts +14 -0
  31. package/lib/menus/index.js +102 -0
  32. package/lib/sidebar/index.d.ts +18 -0
  33. package/lib/sidebar/index.js +155 -0
  34. package/lib/statusBar/index.d.ts +14 -0
  35. package/lib/statusBar/index.js +89 -0
  36. package/lib/statusBar/widget.d.ts +121 -0
  37. package/lib/statusBar/widget.js +237 -0
  38. package/package.json +10 -2
  39. package/schema/launcher.json +70 -0
  40. package/schema/sidebar.json +57 -0
  41. package/src/fileBrowser/fileBrowser.tsx +75 -1
  42. package/src/fileBrowser/gitignore.ts +86 -0
  43. package/src/fileBrowser/index.ts +1 -1
  44. package/src/git/api.ts +20 -0
  45. package/src/git/commands.ts +19 -5
  46. package/src/git/diffWidget.tsx +109 -13
  47. package/src/git/index.ts +32 -6
  48. package/src/git/panelComponent.tsx +17 -5
  49. package/src/index.ts +9 -1
  50. package/src/launcher/agents.ts +248 -0
  51. package/src/launcher/availability.ts +73 -0
  52. package/src/launcher/commands.ts +103 -0
  53. package/src/launcher/dashboard.tsx +356 -0
  54. package/src/launcher/icons.ts +123 -0
  55. package/src/launcher/index.ts +228 -0
  56. package/src/launcher/invocation.ts +45 -0
  57. package/src/menus/index.ts +124 -0
  58. package/src/sidebar/index.ts +195 -0
  59. package/src/statusBar/index.ts +115 -0
  60. package/src/statusBar/widget.tsx +318 -0
  61. package/style/git.css +4 -8
  62. package/style/index.css +2 -0
  63. package/style/index.js +2 -0
  64. package/style/launcher.css +323 -0
  65. package/style/statusBar.css +95 -0
@@ -0,0 +1,115 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+ import { IStatusBar } from '@jupyterlab/statusbar';
6
+ import { ITerminalTracker } from '@jupyterlab/terminal';
7
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
8
+ import { DisposableSet, type IDisposable } from '@lumino/disposable';
9
+
10
+ import { SessionRegistry, TerminalSessionItem } from './widget';
11
+
12
+ const PLUGIN_ID = 'xtralab:statusBar';
13
+
14
+ /**
15
+ * Surfaces every running terminal session as its own item on the left
16
+ * side of the status bar. The list is driven by
17
+ * `serviceManager.terminals.runningChanged`, not by the widget tracker,
18
+ * so a session that has its tab closed but is still running on the
19
+ * backend keeps its slot — the typical state for an agent the user has
20
+ * stepped away from. Clicking an item activates the existing tab if one
21
+ * is open, or reopens the session in a fresh terminal widget. The
22
+ * inline `×` button shuts the session down on the server, which
23
+ * cascades through the widget if it is currently open.
24
+ */
25
+ const plugin: JupyterFrontEndPlugin<void> = {
26
+ id: PLUGIN_ID,
27
+ description:
28
+ 'Surface each running terminal session as its own status bar item so users can jump to (or reopen) backgrounded agents from anywhere in the workspace.',
29
+ autoStart: true,
30
+ requires: [IStatusBar, ITerminalTracker],
31
+ optional: [ITranslator],
32
+ activate: (
33
+ app: JupyterFrontEnd,
34
+ statusBar: IStatusBar,
35
+ tracker: ITerminalTracker,
36
+ translator: ITranslator | null
37
+ ): void => {
38
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
39
+ const registry = new SessionRegistry(app.serviceManager, tracker);
40
+
41
+ // One DisposableSet per session — registerStatusItem returns an
42
+ // IDisposable for the registration, and the item widget itself
43
+ // implements IDisposable. Bundling them lets a single `dispose()`
44
+ // tear the slot down completely when the session goes away.
45
+ const items = new Map<string, IDisposable>();
46
+
47
+ const onActivate = (name: string): void => {
48
+ // `terminal:open` is the upstream entry point that handles both
49
+ // sides of this: if a widget is already attached to the named
50
+ // session it activates that tab, otherwise it spins up a fresh
51
+ // widget connected to the live session. Reusing it keeps the
52
+ // path identical to a click in the running-sessions sidebar.
53
+ void app.commands.execute('terminal:open', { name });
54
+ };
55
+
56
+ const onShutdown = (name: string): void => {
57
+ void app.serviceManager.terminals.shutdown(name).catch(reason => {
58
+ console.error(
59
+ `xtralab: failed to shut down terminal ${name}: ${reason}`
60
+ );
61
+ });
62
+ };
63
+
64
+ const sync = (): void => {
65
+ const live = new Set(registry.sessionNames());
66
+
67
+ // Add items for newly-seen sessions. We register each at its
68
+ // stable rank from the registry so re-creating an item between
69
+ // syncs (e.g. if we ever decide to swap representations) lands
70
+ // in the same slot.
71
+ for (const name of live) {
72
+ if (items.has(name)) {
73
+ continue;
74
+ }
75
+ const item = new TerminalSessionItem({
76
+ registry,
77
+ sessionName: name,
78
+ trans,
79
+ onActivate,
80
+ onShutdown
81
+ });
82
+ const registration = statusBar.registerStatusItem(
83
+ `${PLUGIN_ID}:${name}`,
84
+ {
85
+ item,
86
+ align: 'left',
87
+ rank: registry.rankFor(name)
88
+ }
89
+ );
90
+ const set = new DisposableSet();
91
+ set.add(registration);
92
+ set.add(item);
93
+ items.set(name, set);
94
+ }
95
+
96
+ // Remove items for sessions the server no longer knows about.
97
+ for (const [name, disposable] of Array.from(items.entries())) {
98
+ if (!live.has(name)) {
99
+ disposable.dispose();
100
+ items.delete(name);
101
+ }
102
+ }
103
+ };
104
+
105
+ registry.stateChanged.connect(sync);
106
+ void app.serviceManager.terminals.ready.then(() => {
107
+ // The first `runningChanged` may have fired before our connect,
108
+ // depending on how the manager initialises — refresh once after
109
+ // it is ready so any pre-existing sessions get their items.
110
+ sync();
111
+ });
112
+ }
113
+ };
114
+
115
+ export default plugin;
@@ -0,0 +1,318 @@
1
+ import type { MainAreaWidget } from '@jupyterlab/apputils';
2
+ import type { ServiceManager, Terminal } from '@jupyterlab/services';
3
+ import type { ITerminal, ITerminalTracker } from '@jupyterlab/terminal';
4
+ import {
5
+ type TranslationBundle,
6
+ nullTranslator
7
+ } from '@jupyterlab/translation';
8
+ import {
9
+ closeIcon,
10
+ terminalIcon,
11
+ VDomModel,
12
+ VDomRenderer
13
+ } from '@jupyterlab/ui-components';
14
+ import type { Title, Widget } from '@lumino/widgets';
15
+ import * as React from 'react';
16
+
17
+ /**
18
+ * The widget shape every entry in `ITerminalTracker` takes — kept here
19
+ * so the registry's call sites read cleanly.
20
+ */
21
+ export type TerminalWidget = MainAreaWidget<ITerminal.ITerminal>;
22
+
23
+ /**
24
+ * Source-of-truth model for the status bar. Each running terminal
25
+ * session known to the server is one entry; the registry caches the
26
+ * last `widget.title.label` we observed so the agent name (or any
27
+ * xterm-published title) survives the user closing the tab while the
28
+ * session continues running on the backend.
29
+ *
30
+ * Two upstream signals feed the model:
31
+ * - `serviceManager.terminals.runningChanged` is the authoritative
32
+ * list of live sessions; everything not in it has been shut down
33
+ * server-side and we must drop it.
34
+ * - `tracker.widgetAdded` plus the per-widget `title.changed` /
35
+ * `disposed` signals keep our label cache in sync with whatever
36
+ * xterm/the launcher has set on the open tabs.
37
+ *
38
+ * `stateChanged` is reused for both kinds of update so consumers
39
+ * (the per-session status item, the plugin's add/remove sync) can
40
+ * subscribe once.
41
+ */
42
+ export class SessionRegistry extends VDomModel {
43
+ constructor(
44
+ serviceManager: ServiceManager.IManager,
45
+ tracker: ITerminalTracker
46
+ ) {
47
+ super();
48
+ this._terminals = serviceManager.terminals;
49
+ this._tracker = tracker;
50
+
51
+ this._terminals.runningChanged.connect(this._onRunningChanged, this);
52
+ this._tracker.widgetAdded.connect(this._onWidgetAdded, this);
53
+ this._tracker.forEach(widget => this._trackWidget(widget));
54
+ this._refreshLive();
55
+ }
56
+
57
+ /**
58
+ * Names of all live sessions, ordered by their stable rank so the
59
+ * status bar items keep their slots as new sessions are added.
60
+ */
61
+ sessionNames(): string[] {
62
+ const names = Array.from(this._live);
63
+ names.sort((a, b) => this.rankFor(a) - this.rankFor(b));
64
+ return names;
65
+ }
66
+
67
+ /**
68
+ * True iff the named session is still on the server. Used by the
69
+ * per-session item to render `null` during the brief window between
70
+ * the session's shutdown and the plugin disposing the item.
71
+ */
72
+ has(name: string): boolean {
73
+ return this._live.has(name);
74
+ }
75
+
76
+ /**
77
+ * Display name for the session. The cache wins because it only
78
+ * holds "real" labels (the launcher's agent name or an xterm escape
79
+ * sequence — see `_cacheLabel` for the filter); reading it first
80
+ * prevents the transient `Terminal {name}` an XTerm widget shows
81
+ * during reconnect from flickering into the status bar. The live
82
+ * widget title is used when no cache exists, and the
83
+ * `Terminal {name}` fallback covers sessions we have never seen a
84
+ * widget for (e.g. surviving a lab reload).
85
+ */
86
+ labelFor(name: string): string {
87
+ const cached = this._labels.get(name);
88
+ if (cached) {
89
+ return cached;
90
+ }
91
+ const widget = this.widgetFor(name);
92
+ if (widget && widget.title.label) {
93
+ return widget.title.label;
94
+ }
95
+ return `Terminal ${name}`;
96
+ }
97
+
98
+ /**
99
+ * Return the open widget for a session, if any. The status item
100
+ * uses this to switch behaviour between "activate existing tab"
101
+ * and "open a new tab connected to the live session".
102
+ */
103
+ widgetFor(name: string): TerminalWidget | null {
104
+ return (
105
+ this._tracker.find(widget => widget.content.session.name === name) ?? null
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Stable per-session rank assigned in observation order. Reused
111
+ * across `registerStatusItem` calls so an item that is briefly
112
+ * unregistered (e.g. while a widget is being recreated) lands back
113
+ * in the same slot. Cleaned up when the session is shut down so the
114
+ * counter does not grow without bound across long sessions.
115
+ */
116
+ rankFor(name: string): number {
117
+ let rank = this._ranks.get(name);
118
+ if (rank === undefined) {
119
+ rank = this._nextRank++;
120
+ this._ranks.set(name, rank);
121
+ }
122
+ return rank;
123
+ }
124
+
125
+ dispose(): void {
126
+ if (this.isDisposed) {
127
+ return;
128
+ }
129
+ this._terminals.runningChanged.disconnect(this._onRunningChanged, this);
130
+ this._tracker.widgetAdded.disconnect(this._onWidgetAdded, this);
131
+ this._tracker.forEach(widget => this._untrackWidget(widget));
132
+ super.dispose();
133
+ }
134
+
135
+ private _refreshLive(): void {
136
+ const next = new Set<string>();
137
+ for (const model of this._terminals.running()) {
138
+ next.add(model.name);
139
+ }
140
+ this._live = next;
141
+ // Drop cache + rank entries for sessions that have been shut down
142
+ // server-side. Without this prune the rank counter would keep
143
+ // climbing across long-running labs as terminals come and go.
144
+ for (const name of Array.from(this._labels.keys())) {
145
+ if (!next.has(name)) {
146
+ this._labels.delete(name);
147
+ }
148
+ }
149
+ for (const name of Array.from(this._ranks.keys())) {
150
+ if (!next.has(name)) {
151
+ this._ranks.delete(name);
152
+ }
153
+ }
154
+ }
155
+
156
+ private _onRunningChanged(_: unknown, sessions: Terminal.IModel[]): void {
157
+ this._live = new Set(sessions.map(model => model.name));
158
+ for (const name of Array.from(this._labels.keys())) {
159
+ if (!this._live.has(name)) {
160
+ this._labels.delete(name);
161
+ }
162
+ }
163
+ for (const name of Array.from(this._ranks.keys())) {
164
+ if (!this._live.has(name)) {
165
+ this._ranks.delete(name);
166
+ }
167
+ }
168
+ this.stateChanged.emit();
169
+ }
170
+
171
+ private _onWidgetAdded(_: unknown, widget: TerminalWidget): void {
172
+ this._trackWidget(widget);
173
+ this.stateChanged.emit();
174
+ }
175
+
176
+ private _trackWidget(widget: TerminalWidget): void {
177
+ const name = widget.content.session.name;
178
+ this._cacheLabel(name, widget.title.label);
179
+ widget.title.changed.connect(this._onTitleChanged, this);
180
+ widget.disposed.connect(this._onWidgetDisposed, this);
181
+ }
182
+
183
+ private _untrackWidget(widget: TerminalWidget): void {
184
+ widget.title.changed.disconnect(this._onTitleChanged, this);
185
+ widget.disposed.disconnect(this._onWidgetDisposed, this);
186
+ }
187
+
188
+ private _onTitleChanged(title: Title<Widget>): void {
189
+ const owner = title.owner as TerminalWidget;
190
+ const session = owner.content?.session;
191
+ if (!session) {
192
+ return;
193
+ }
194
+ this._cacheLabel(session.name, title.label);
195
+ this.stateChanged.emit();
196
+ }
197
+
198
+ /**
199
+ * Update the cached label for a session, but only when the new
200
+ * label is "real" — non-empty and not one of the transient defaults
201
+ * the XTerm widget cycles through during reconnect (`'...'` while
202
+ * the websocket is opening, `'Terminal {name}'` once
203
+ * `_initialConnection` fires). Without the filter, briefly
204
+ * reopening a tab while an agent has yet to re-emit its xterm
205
+ * title escape sequence would clobber the cached agent name with
206
+ * `Terminal 1`, which is exactly the regression we are guarding
207
+ * against. Live widget titles still display whatever the widget
208
+ * currently holds because `labelFor` consults the widget first;
209
+ * the filter only affects what survives a tab close.
210
+ */
211
+ private _cacheLabel(name: string, label: string): void {
212
+ if (!label) {
213
+ return;
214
+ }
215
+ if (label === '...' || label === `Terminal ${name}`) {
216
+ return;
217
+ }
218
+ this._labels.set(name, label);
219
+ }
220
+
221
+ private _onWidgetDisposed(widget: Widget): void {
222
+ // The widget is gone but the session may still be running on the
223
+ // server — keep the cached label so the status item keeps the
224
+ // agent's name when the user reopens the tab. We only clean up
225
+ // our subscriptions here; the cache is purged by `runningChanged`
226
+ // once the session itself goes away.
227
+ this._untrackWidget(widget as TerminalWidget);
228
+ this.stateChanged.emit();
229
+ }
230
+
231
+ private _terminals: Terminal.IManager;
232
+ private _tracker: ITerminalTracker;
233
+ private _labels = new Map<string, string>();
234
+ private _ranks = new Map<string, number>();
235
+ private _live = new Set<string>();
236
+ private _nextRank = 100;
237
+ }
238
+
239
+ /**
240
+ * One status bar item per live terminal session. Renders a terminal
241
+ * icon plus the session's label, with an inline shutdown button that
242
+ * appears on hover/focus. The item delegates click handling to the
243
+ * plugin so the lifecycle (create/dispose) and the user-facing
244
+ * behaviour (activate vs. reopen, shutdown) live in one place.
245
+ */
246
+ export class TerminalSessionItem extends VDomRenderer<SessionRegistry> {
247
+ constructor(options: TerminalSessionItem.IOptions) {
248
+ super(options.registry);
249
+ this._sessionName = options.sessionName;
250
+ this._trans = options.trans ?? nullTranslator.load('jupyterlab');
251
+ this._onActivate = options.onActivate;
252
+ this._onShutdown = options.onShutdown;
253
+ this.addClass('jp-mod-highlighted');
254
+ this.addClass('jp-xtralab-StatusBar-Terminal');
255
+ }
256
+
257
+ render(): React.ReactElement | null {
258
+ if (!this.model || !this.model.has(this._sessionName)) {
259
+ return null;
260
+ }
261
+ const trans = this._trans;
262
+ const label = this.model.labelFor(this._sessionName);
263
+ const hasWidget = this.model.widgetFor(this._sessionName) !== null;
264
+ const tooltip = hasWidget
265
+ ? trans.__('Activate %1', label)
266
+ : trans.__('Reopen %1', label);
267
+ this.node.title = tooltip;
268
+
269
+ return (
270
+ <div className="jp-xtralab-StatusBar-Terminal-Container">
271
+ <button
272
+ type="button"
273
+ className="jp-xtralab-StatusBar-Terminal-Activate"
274
+ onClick={() => this._onActivate(this._sessionName)}
275
+ aria-label={tooltip}
276
+ >
277
+ <terminalIcon.react
278
+ tag="span"
279
+ verticalAlign="middle"
280
+ stylesheet="statusBar"
281
+ />
282
+ <span className="jp-xtralab-StatusBar-Terminal-Label">{label}</span>
283
+ </button>
284
+ <button
285
+ type="button"
286
+ className="jp-xtralab-StatusBar-Terminal-Close"
287
+ onClick={event => {
288
+ event.stopPropagation();
289
+ this._onShutdown(this._sessionName);
290
+ }}
291
+ title={trans.__('Shut down %1', label)}
292
+ aria-label={trans.__('Shut down %1', label)}
293
+ >
294
+ <closeIcon.react
295
+ tag="span"
296
+ verticalAlign="middle"
297
+ stylesheet="statusBar"
298
+ />
299
+ </button>
300
+ </div>
301
+ );
302
+ }
303
+
304
+ private _sessionName: string;
305
+ private _trans: TranslationBundle;
306
+ private _onActivate: (sessionName: string) => void;
307
+ private _onShutdown: (sessionName: string) => void;
308
+ }
309
+
310
+ export namespace TerminalSessionItem {
311
+ export interface IOptions {
312
+ registry: SessionRegistry;
313
+ sessionName: string;
314
+ trans?: TranslationBundle;
315
+ onActivate: (sessionName: string) => void;
316
+ onShutdown: (sessionName: string) => void;
317
+ }
318
+ }
package/style/git.css CHANGED
@@ -39,14 +39,6 @@
39
39
  min-width: 0;
40
40
  }
41
41
 
42
- .jp-xtralab-GitPanel-title {
43
- font-weight: 600;
44
- text-transform: uppercase;
45
- font-size: var(--jp-ui-font-size0);
46
- letter-spacing: 0.04em;
47
- color: var(--jp-ui-font-color2);
48
- }
49
-
50
42
  .jp-xtralab-GitPanel-repo {
51
43
  font-weight: 600;
52
44
  white-space: nowrap;
@@ -290,6 +282,10 @@
290
282
  color: var(--jp-ui-font-color1);
291
283
  }
292
284
 
285
+ .lm-TabBar-tab.jp-mod-preview .lm-TabBar-tabLabel {
286
+ font-style: italic;
287
+ }
288
+
293
289
  /*
294
290
  * Flex-column container: an optional toolbar at the top (notebook view
295
291
  * mode toggle) plus a body that hosts the scrollable diff and the
package/style/index.css CHANGED
@@ -1,2 +1,4 @@
1
1
  @import url('base.css');
2
2
  @import url('git.css');
3
+ @import url('launcher.css');
4
+ @import url('statusBar.css');
package/style/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  import './base.css';
2
2
  import './git.css';
3
+ import './launcher.css';
4
+ import './statusBar.css';