xtralab 0.5.0 → 0.6.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 (71) hide show
  1. package/README.md +110 -78
  2. package/lib/agentSessions.d.ts +50 -0
  3. package/lib/agentSessions.js +45 -0
  4. package/lib/fileBrowser/index.js +5 -1
  5. package/lib/git/imageDiff.js +80 -8
  6. package/lib/index.d.ts +3 -2
  7. package/lib/index.js +11 -6
  8. package/lib/launcher/agents.d.ts +1 -1
  9. package/lib/launcher/agents.js +22 -13
  10. package/lib/launcher/commands.d.ts +32 -6
  11. package/lib/launcher/commands.js +81 -57
  12. package/lib/launcher/dashboard.d.ts +15 -0
  13. package/lib/launcher/dashboard.js +119 -5
  14. package/lib/launcher/editorRegistry.d.ts +43 -0
  15. package/lib/launcher/editorRegistry.js +96 -0
  16. package/lib/launcher/editors.d.ts +78 -0
  17. package/lib/launcher/editors.js +110 -0
  18. package/lib/launcher/icons.d.ts +25 -6
  19. package/lib/launcher/icons.js +116 -19
  20. package/lib/launcher/index.d.ts +2 -22
  21. package/lib/launcher/index.js +48 -25
  22. package/lib/launcher/registry.d.ts +21 -0
  23. package/lib/launcher/registry.js +28 -0
  24. package/lib/launcher/tokens.d.ts +41 -0
  25. package/lib/launcher/tokens.js +17 -0
  26. package/lib/terminals/detection.d.ts +11 -0
  27. package/lib/terminals/detection.js +50 -0
  28. package/lib/terminals/index.d.ts +27 -0
  29. package/lib/terminals/index.js +194 -0
  30. package/lib/terminals/model.d.ts +191 -0
  31. package/lib/terminals/model.js +418 -0
  32. package/lib/terminals/widget.d.ts +72 -0
  33. package/lib/terminals/widget.js +93 -0
  34. package/lib/topBar/icons.d.ts +13 -0
  35. package/lib/topBar/icons.js +27 -0
  36. package/lib/topBar/index.d.ts +16 -0
  37. package/lib/topBar/index.js +86 -0
  38. package/package.json +36 -33
  39. package/schema/launcher.json +56 -1
  40. package/src/agentSessions.ts +94 -0
  41. package/src/fileBrowser/index.ts +5 -1
  42. package/src/git/imageDiff.tsx +127 -26
  43. package/src/index.ts +11 -6
  44. package/src/launcher/agents.ts +23 -14
  45. package/src/launcher/commands.ts +98 -60
  46. package/src/launcher/dashboard.tsx +205 -3
  47. package/src/launcher/editorRegistry.ts +149 -0
  48. package/src/launcher/editors.ts +183 -0
  49. package/src/launcher/icons.ts +120 -20
  50. package/src/launcher/index.ts +62 -27
  51. package/src/launcher/registry.ts +33 -0
  52. package/src/launcher/tokens.ts +51 -0
  53. package/src/terminals/detection.ts +65 -0
  54. package/src/terminals/index.ts +222 -0
  55. package/src/terminals/model.ts +497 -0
  56. package/src/terminals/widget.tsx +240 -0
  57. package/src/topBar/icons.ts +30 -0
  58. package/src/topBar/index.ts +121 -0
  59. package/style/git.css +109 -0
  60. package/style/index.css +2 -1
  61. package/style/index.js +2 -1
  62. package/style/launcher.css +124 -2
  63. package/style/terminals.css +235 -0
  64. package/style/topBar.css +38 -0
  65. package/lib/statusBar/index.d.ts +0 -14
  66. package/lib/statusBar/index.js +0 -89
  67. package/lib/statusBar/widget.d.ts +0 -121
  68. package/lib/statusBar/widget.js +0 -237
  69. package/src/statusBar/index.ts +0 -115
  70. package/src/statusBar/widget.tsx +0 -318
  71. package/style/statusBar.css +0 -95
@@ -0,0 +1,497 @@
1
+ import type { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import type { MainAreaWidget } from '@jupyterlab/apputils';
3
+ import type { ServiceManager, Terminal } from '@jupyterlab/services';
4
+ import type { ITerminal, ITerminalTracker } from '@jupyterlab/terminal';
5
+ import type { IDisposable } from '@lumino/disposable';
6
+ import { Poll } from '@lumino/polling';
7
+ import { ISignal, Signal } from '@lumino/signaling';
8
+ import type { Title, Widget } from '@lumino/widgets';
9
+
10
+ import type { IAgentSessions } from '../agentSessions';
11
+ import { fetchRunningAgents } from './detection';
12
+
13
+ /**
14
+ * The widget shape every entry in `ITerminalTracker` takes — kept here
15
+ * so the registry's call sites read cleanly.
16
+ */
17
+ export type TerminalWidget = MainAreaWidget<ITerminal.ITerminal>;
18
+
19
+ /**
20
+ * How often to ask the server which agent (if any) is running in each
21
+ * terminal. Snappy enough that a manually-started agent's logo shows up
22
+ * within a few seconds, light enough that walking the shells' process trees
23
+ * stays negligible.
24
+ */
25
+ const DETECT_POLL_INTERVAL_MS = 3000;
26
+
27
+ /**
28
+ * Upper bound on the exponential backoff when detection fails repeatedly
29
+ * (e.g. the endpoint is missing on an older server). Matches the rest of the
30
+ * plugin's polls.
31
+ */
32
+ const DETECT_POLL_MAX_MS = 300_000;
33
+
34
+ /**
35
+ * Grace window after a session first appears during which an optimistic
36
+ * launch tag outranks a `null` detection result. It covers the gap between
37
+ * issuing an agent's command and its process actually being spawnable, so the
38
+ * freshly-launched logo doesn't blink off if a detection poll lands in that
39
+ * sliver. Comfortably longer than a cold agent start; short enough that a tag
40
+ * for an agent that never really started clears quickly.
41
+ */
42
+ const LAUNCH_GRACE_MS = 4000;
43
+
44
+ /**
45
+ * Source-of-truth model for the running-terminals panel. Each running
46
+ * terminal session known to the server is one entry; the registry caches
47
+ * the last `widget.title.label` we observed so the agent name (or any
48
+ * xterm-published title) survives the user closing the tab while the
49
+ * session continues running on the backend.
50
+ *
51
+ * It also resolves *which agent is running* in each session, so the panel can
52
+ * badge rows with the agent's logo. Two inputs feed that, reconciled by
53
+ * {@link agentCommandFor}:
54
+ * - an optimistic launch tag ({@link IAgentSessions}) written when we start
55
+ * an agent ourselves — instant, but blind to the agent later exiting; and
56
+ * - authoritative server-side process detection, polled here, which works
57
+ * for hand-started agents too and clears once an agent exits.
58
+ *
59
+ * Upstream session signals:
60
+ * - `serviceManager.terminals.runningChanged` is the authoritative
61
+ * list of live sessions; everything not in it has been shut down
62
+ * server-side and we must drop it.
63
+ * - `tracker.widgetAdded` plus the per-widget `title.changed` /
64
+ * `disposed` signals keep our label cache in sync with whatever
65
+ * xterm/the launcher has set on the open tabs.
66
+ *
67
+ * `stateChanged` is emitted for every kind of update; the panel hooks it
68
+ * through a `UseSignal` so a single subscription re-renders the list on
69
+ * any change.
70
+ */
71
+ export class SessionRegistry implements IDisposable {
72
+ constructor(options: SessionRegistry.IOptions) {
73
+ this._terminals = options.serviceManager.terminals;
74
+ this._tracker = options.tracker;
75
+ this._agentSessions = options.agentSessions ?? null;
76
+ this._detectCommands = options.detectCommands ?? (() => []);
77
+ this._shell = options.shell ?? null;
78
+
79
+ this._terminals.runningChanged.connect(this._onRunningChanged, this);
80
+ this._tracker.widgetAdded.connect(this._onWidgetAdded, this);
81
+ this._tracker.forEach(widget => this._trackWidget(widget));
82
+ this._refreshLive();
83
+
84
+ // Track which terminal is the current widget in the main area so the panel
85
+ // can highlight its row — and highlight nothing while a notebook or any
86
+ // other non-terminal tab is current instead. `currentChanged` is optional
87
+ // on the shell interface (not every shell can switch focus), so guard it;
88
+ // when it is absent the highlight stays off.
89
+ this._shell?.currentChanged?.connect(this._onShellCurrentChanged, this);
90
+ this._updateCurrent();
91
+
92
+ // A freshly written launch tag should re-render the panel immediately
93
+ // (show the logo) and again once the grace window closes (so a tag for
94
+ // an agent that never started, or that detection later contradicts,
95
+ // doesn't linger).
96
+ this._agentSessions?.changed.connect(this._onTagChanged, this);
97
+
98
+ this._poll = new Poll({
99
+ name: '@xtralab/terminals:runningAgents',
100
+ factory: () => this._refreshDetection(),
101
+ frequency: {
102
+ interval: DETECT_POLL_INTERVAL_MS,
103
+ backoff: true,
104
+ max: DETECT_POLL_MAX_MS
105
+ },
106
+ standby: 'when-hidden'
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Emitted whenever the live session list, a cached label, or the detected
112
+ * running agent changes.
113
+ */
114
+ get stateChanged(): ISignal<this, void> {
115
+ return this._stateChanged;
116
+ }
117
+
118
+ get isDisposed(): boolean {
119
+ return this._isDisposed;
120
+ }
121
+
122
+ /**
123
+ * Names of all live sessions, ordered by their stable rank so the
124
+ * rendered list keeps a consistent order (roughly creation order) as
125
+ * new sessions are added and old ones shut down.
126
+ */
127
+ sessionNames(): string[] {
128
+ const names = Array.from(this._live);
129
+ names.sort((a, b) => this.rankFor(a) - this.rankFor(b));
130
+ return names;
131
+ }
132
+
133
+ /**
134
+ * True iff the named session is still on the server. Used by the panel
135
+ * to skip a row during the brief window between the session's shutdown
136
+ * and the next `runningChanged` arriving.
137
+ */
138
+ has(name: string): boolean {
139
+ return this._live.has(name);
140
+ }
141
+
142
+ /**
143
+ * Display name for the session. The cache wins because it only
144
+ * holds "real" labels (the launcher's agent name or an xterm escape
145
+ * sequence — see `_cacheLabel` for the filter); reading it first
146
+ * prevents the transient `Terminal {name}` an XTerm widget shows
147
+ * during reconnect from flickering into the panel. The live widget
148
+ * title is used when no cache exists, and the `Terminal {name}`
149
+ * fallback covers sessions we have never seen a widget for (e.g.
150
+ * surviving a lab reload).
151
+ */
152
+ labelFor(name: string): string {
153
+ const cached = this._labels.get(name);
154
+ if (cached) {
155
+ return cached;
156
+ }
157
+ const widget = this.widgetFor(name);
158
+ if (widget && widget.title.label) {
159
+ return widget.title.label;
160
+ }
161
+ return `Terminal ${name}`;
162
+ }
163
+
164
+ /**
165
+ * The command of the agent running in the session, or `null` if none.
166
+ *
167
+ * Server-side detection is authoritative whenever it reports a running
168
+ * agent. A launch tag fills two gaps: the startup grace window right after
169
+ * we launch an agent (so its logo shows before its process is detectable),
170
+ * and any time detection is unavailable (older server, transient error).
171
+ * Once the grace window has passed and detection has reported the session
172
+ * as idle, the tag is ignored — that is how the badge clears when an agent
173
+ * exits.
174
+ */
175
+ agentCommandFor(name: string): string | null {
176
+ // `string` = detected running agent, `null` = polled and idle,
177
+ // `undefined` = not covered by a successful poll yet.
178
+ const detected = this._detected.has(name)
179
+ ? this._detected.get(name)!
180
+ : undefined;
181
+ if (typeof detected === 'string') {
182
+ return detected;
183
+ }
184
+ const tagged = this._agentSessions?.get(name) ?? null;
185
+ if (tagged !== null) {
186
+ const seenAt = this._firstSeen.get(name) ?? 0;
187
+ const inGrace = Date.now() - seenAt < LAUNCH_GRACE_MS;
188
+ if (inGrace || detected === undefined) {
189
+ return tagged;
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * Return the open widget for a session, if any. The panel uses this to
197
+ * switch behaviour between "activate existing tab" and "open a new tab
198
+ * connected to the live session".
199
+ */
200
+ widgetFor(name: string): TerminalWidget | null {
201
+ return (
202
+ this._tracker.find(widget => widget.content.session.name === name) ?? null
203
+ );
204
+ }
205
+
206
+ /**
207
+ * Session name of the terminal that is currently the active widget in the
208
+ * shell's main area, or `null` when that widget is not a terminal (for
209
+ * example a notebook or text editor is current). The panel uses this to
210
+ * highlight the current terminal's row — mirroring how the file browser
211
+ * surfaces the open document — and to leave every row unhighlighted while a
212
+ * non-terminal tab is current.
213
+ */
214
+ currentSessionName(): string | null {
215
+ return this._currentName;
216
+ }
217
+
218
+ /**
219
+ * Stable per-session rank assigned in observation order. Used to keep
220
+ * the rendered list in a steady order so rows don't reshuffle as
221
+ * sessions come and go. Cleaned up when the session is shut down so the
222
+ * counter does not grow without bound across long sessions.
223
+ */
224
+ rankFor(name: string): number {
225
+ let rank = this._ranks.get(name);
226
+ if (rank === undefined) {
227
+ rank = this._nextRank++;
228
+ this._ranks.set(name, rank);
229
+ }
230
+ return rank;
231
+ }
232
+
233
+ dispose(): void {
234
+ if (this._isDisposed) {
235
+ return;
236
+ }
237
+ this._isDisposed = true;
238
+ this._poll.dispose();
239
+ this._terminals.runningChanged.disconnect(this._onRunningChanged, this);
240
+ this._tracker.widgetAdded.disconnect(this._onWidgetAdded, this);
241
+ this._agentSessions?.changed.disconnect(this._onTagChanged, this);
242
+ this._shell?.currentChanged?.disconnect(this._onShellCurrentChanged, this);
243
+ this._tracker.forEach(widget => this._untrackWidget(widget));
244
+ Signal.clearData(this);
245
+ }
246
+
247
+ private _refreshLive(): void {
248
+ const next = new Set<string>();
249
+ for (const model of this._terminals.running()) {
250
+ next.add(model.name);
251
+ }
252
+ this._reconcileLive(next);
253
+ }
254
+
255
+ private _onRunningChanged(_: unknown, sessions: Terminal.IModel[]): void {
256
+ this._reconcileLive(new Set(sessions.map(model => model.name)));
257
+ this._stateChanged.emit();
258
+ }
259
+
260
+ /**
261
+ * Adopt `next` as the live set and prune every per-session map for
262
+ * sessions that have gone away — labels, ranks, first-seen timestamps,
263
+ * detection results, and the shared launch tag. Without this the maps (and
264
+ * the rank counter) would grow without bound as terminals come and go.
265
+ */
266
+ private _reconcileLive(next: Set<string>): void {
267
+ // Names we had already seen, captured before we prune below — the set of
268
+ // candidates whose launch tag may now need forgetting. (The tag map is
269
+ // not enumerable, so we drive the prune from the sessions we know about;
270
+ // `_firstSeen` has an entry for every session that has ever been live.)
271
+ const known = Array.from(this._firstSeen.keys());
272
+
273
+ for (const name of next) {
274
+ if (!this._firstSeen.has(name)) {
275
+ this._firstSeen.set(name, Date.now());
276
+ }
277
+ }
278
+ this._live = next;
279
+ for (const name of Array.from(this._labels.keys())) {
280
+ if (!next.has(name)) {
281
+ this._labels.delete(name);
282
+ }
283
+ }
284
+ for (const name of Array.from(this._ranks.keys())) {
285
+ if (!next.has(name)) {
286
+ this._ranks.delete(name);
287
+ }
288
+ }
289
+ for (const name of Array.from(this._firstSeen.keys())) {
290
+ if (!next.has(name)) {
291
+ this._firstSeen.delete(name);
292
+ }
293
+ }
294
+ for (const name of Array.from(this._detected.keys())) {
295
+ if (!next.has(name)) {
296
+ this._detected.delete(name);
297
+ }
298
+ }
299
+ // Forget launch tags for sessions that have gone away. This matters for
300
+ // correctness as well as bookkeeping: terminado reuses session names, so
301
+ // a stale tag could otherwise mislabel a brand-new terminal that happens
302
+ // to reuse a closed session's name.
303
+ for (const name of known) {
304
+ if (!next.has(name)) {
305
+ this._agentSessions?.delete(name);
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Poll body: ask the server which agent runs in each terminal and update
312
+ * the detection map. On failure we keep the previous results rather than
313
+ * clearing them, so a transient error doesn't strip every badge.
314
+ */
315
+ private async _refreshDetection(): Promise<void> {
316
+ const commands = this._detectCommands();
317
+ if (commands.length === 0) {
318
+ if (this._detected.size > 0) {
319
+ this._detected = new Map();
320
+ this._stateChanged.emit();
321
+ }
322
+ return;
323
+ }
324
+ const result = await fetchRunningAgents(commands);
325
+ if (result === null) {
326
+ return;
327
+ }
328
+ const next = new Map<string, string | null>();
329
+ for (const [name, command] of Object.entries(result)) {
330
+ if (this._live.has(name)) {
331
+ next.set(name, command);
332
+ }
333
+ }
334
+ if (!Private.detectedEqual(this._detected, next)) {
335
+ this._detected = next;
336
+ this._stateChanged.emit();
337
+ }
338
+ }
339
+
340
+ private _onTagChanged(): void {
341
+ this._stateChanged.emit();
342
+ // Re-render once the grace window closes so a tag that detection never
343
+ // confirmed (or has since contradicted) stops being shown.
344
+ setTimeout(() => {
345
+ if (!this._isDisposed) {
346
+ this._stateChanged.emit();
347
+ }
348
+ }, LAUNCH_GRACE_MS + 100);
349
+ }
350
+
351
+ private _onWidgetAdded(_: unknown, widget: TerminalWidget): void {
352
+ this._trackWidget(widget);
353
+ this._stateChanged.emit();
354
+ }
355
+
356
+ private _onShellCurrentChanged(): void {
357
+ this._updateCurrent();
358
+ }
359
+
360
+ /**
361
+ * Recompute which terminal (if any) is the active main-area widget and emit
362
+ * only when it changes. The terminal tracker tells us whether the shell's
363
+ * current widget is one of our terminals; anything else — a notebook, an
364
+ * editor, or nothing — clears the highlight.
365
+ */
366
+ private _updateCurrent(): void {
367
+ const current = this._shell?.currentWidget ?? null;
368
+ const name =
369
+ current && this._tracker.has(current)
370
+ ? (current as TerminalWidget).content.session.name
371
+ : null;
372
+ if (name !== this._currentName) {
373
+ this._currentName = name;
374
+ this._stateChanged.emit();
375
+ }
376
+ }
377
+
378
+ private _trackWidget(widget: TerminalWidget): void {
379
+ const name = widget.content.session.name;
380
+ this._cacheLabel(name, widget.title.label);
381
+ widget.title.changed.connect(this._onTitleChanged, this);
382
+ widget.disposed.connect(this._onWidgetDisposed, this);
383
+ }
384
+
385
+ private _untrackWidget(widget: TerminalWidget): void {
386
+ widget.title.changed.disconnect(this._onTitleChanged, this);
387
+ widget.disposed.disconnect(this._onWidgetDisposed, this);
388
+ }
389
+
390
+ private _onTitleChanged(title: Title<Widget>): void {
391
+ const owner = title.owner as TerminalWidget;
392
+ const session = owner.content?.session;
393
+ if (!session) {
394
+ return;
395
+ }
396
+ this._cacheLabel(session.name, title.label);
397
+ this._stateChanged.emit();
398
+ }
399
+
400
+ /**
401
+ * Update the cached label for a session, but only when the new
402
+ * label is "real" — non-empty and not one of the transient defaults
403
+ * the XTerm widget cycles through during reconnect (`'...'` while
404
+ * the websocket is opening, `'Terminal {name}'` once
405
+ * `_initialConnection` fires). Without the filter, briefly
406
+ * reopening a tab while an agent has yet to re-emit its xterm
407
+ * title escape sequence would clobber the cached agent name with
408
+ * `Terminal 1`, which is exactly the regression we are guarding
409
+ * against. Live widget titles still display whatever the widget
410
+ * currently holds because `labelFor` consults the widget first;
411
+ * the filter only affects what survives a tab close.
412
+ */
413
+ private _cacheLabel(name: string, label: string): void {
414
+ if (!label) {
415
+ return;
416
+ }
417
+ if (label === '...' || label === `Terminal ${name}`) {
418
+ return;
419
+ }
420
+ this._labels.set(name, label);
421
+ }
422
+
423
+ private _onWidgetDisposed(widget: Widget): void {
424
+ // The widget is gone but the session may still be running on the
425
+ // server — keep the cached label so the panel keeps the agent's name
426
+ // when the user reopens the tab. We only clean up our subscriptions
427
+ // here; the cache is purged by `runningChanged` once the session
428
+ // itself goes away.
429
+ this._untrackWidget(widget as TerminalWidget);
430
+ this._stateChanged.emit();
431
+ }
432
+
433
+ private _terminals: Terminal.IManager;
434
+ private _tracker: ITerminalTracker;
435
+ private _agentSessions: IAgentSessions | null;
436
+ private _detectCommands: () => string[];
437
+ private _shell: JupyterFrontEnd.IShell | null;
438
+ private _poll: Poll;
439
+ private _labels = new Map<string, string>();
440
+ private _ranks = new Map<string, number>();
441
+ private _firstSeen = new Map<string, number>();
442
+ private _detected = new Map<string, string | null>();
443
+ private _live = new Set<string>();
444
+ private _currentName: string | null = null;
445
+ private _nextRank = 100;
446
+ private _isDisposed = false;
447
+ private _stateChanged = new Signal<this, void>(this);
448
+ }
449
+
450
+ /**
451
+ * Construction options for {@link SessionRegistry}.
452
+ */
453
+ export namespace SessionRegistry {
454
+ export interface IOptions {
455
+ serviceManager: ServiceManager.IManager;
456
+ tracker: ITerminalTracker;
457
+ /**
458
+ * The application shell, used to tell which widget is currently active so
459
+ * the panel can highlight the terminal that is the current main-area
460
+ * widget — and highlight nothing when that widget is not a terminal (a
461
+ * notebook, an editor, …). Optional: without it, or on a shell that cannot
462
+ * report `currentChanged`, the highlight stays off.
463
+ */
464
+ shell?: JupyterFrontEnd.IShell | null;
465
+ /**
466
+ * Shared launch-tag registry. When present, its records seed each row's
467
+ * agent badge until server-side detection takes over.
468
+ */
469
+ agentSessions?: IAgentSessions | null;
470
+ /**
471
+ * Returns the agent commands the server should look for when detecting
472
+ * running agents. Read on every poll so it tracks the live agent list.
473
+ */
474
+ detectCommands?: () => string[];
475
+ }
476
+ }
477
+
478
+ namespace Private {
479
+ /**
480
+ * Value-equality for two detection maps, so a poll that changes nothing
481
+ * doesn't trigger a re-render.
482
+ */
483
+ export function detectedEqual(
484
+ a: Map<string, string | null>,
485
+ b: Map<string, string | null>
486
+ ): boolean {
487
+ if (a.size !== b.size) {
488
+ return false;
489
+ }
490
+ for (const [name, command] of a) {
491
+ if (!b.has(name) || b.get(name) !== command) {
492
+ return false;
493
+ }
494
+ }
495
+ return true;
496
+ }
497
+ }