xtralab 0.4.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 (96) hide show
  1. package/README.md +110 -73
  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/commands.d.ts +12 -13
  6. package/lib/git/commands.js +7 -28
  7. package/lib/git/diffProvider.d.ts +8 -0
  8. package/lib/git/diffProvider.js +314 -0
  9. package/lib/git/diffSurface.d.ts +115 -0
  10. package/lib/git/diffSurface.js +342 -0
  11. package/lib/git/diffWidget.d.ts +8 -13
  12. package/lib/git/diffWidget.js +65 -311
  13. package/lib/git/imageDiff.d.ts +23 -0
  14. package/lib/git/imageDiff.js +256 -0
  15. package/lib/git/index.d.ts +10 -8
  16. package/lib/git/index.js +37 -52
  17. package/lib/index.d.ts +4 -2
  18. package/lib/index.js +14 -8
  19. package/lib/launcher/agents.d.ts +1 -1
  20. package/lib/launcher/agents.js +22 -13
  21. package/lib/launcher/commands.d.ts +32 -6
  22. package/lib/launcher/commands.js +81 -57
  23. package/lib/launcher/dashboard.d.ts +15 -0
  24. package/lib/launcher/dashboard.js +119 -5
  25. package/lib/launcher/editorRegistry.d.ts +43 -0
  26. package/lib/launcher/editorRegistry.js +96 -0
  27. package/lib/launcher/editors.d.ts +78 -0
  28. package/lib/launcher/editors.js +110 -0
  29. package/lib/launcher/icons.d.ts +25 -6
  30. package/lib/launcher/icons.js +116 -19
  31. package/lib/launcher/index.d.ts +2 -22
  32. package/lib/launcher/index.js +48 -25
  33. package/lib/launcher/registry.d.ts +21 -0
  34. package/lib/launcher/registry.js +28 -0
  35. package/lib/launcher/tokens.d.ts +41 -0
  36. package/lib/launcher/tokens.js +17 -0
  37. package/lib/terminals/detection.d.ts +11 -0
  38. package/lib/terminals/detection.js +50 -0
  39. package/lib/terminals/index.d.ts +27 -0
  40. package/lib/terminals/index.js +194 -0
  41. package/lib/terminals/model.d.ts +191 -0
  42. package/lib/terminals/model.js +418 -0
  43. package/lib/terminals/widget.d.ts +72 -0
  44. package/lib/terminals/widget.js +93 -0
  45. package/lib/topBar/icons.d.ts +13 -0
  46. package/lib/topBar/icons.js +27 -0
  47. package/lib/topBar/index.d.ts +16 -0
  48. package/lib/topBar/index.js +86 -0
  49. package/package.json +52 -35
  50. package/schema/launcher.json +56 -1
  51. package/src/agentSessions.ts +94 -0
  52. package/src/fileBrowser/index.ts +5 -1
  53. package/src/git/commands.ts +12 -38
  54. package/src/git/diffProvider.tsx +462 -0
  55. package/src/git/diffSurface.tsx +588 -0
  56. package/src/git/diffWidget.tsx +105 -465
  57. package/src/git/imageDiff.tsx +469 -0
  58. package/src/git/index.ts +38 -68
  59. package/src/index.ts +14 -8
  60. package/src/launcher/agents.ts +23 -14
  61. package/src/launcher/commands.ts +98 -60
  62. package/src/launcher/dashboard.tsx +205 -3
  63. package/src/launcher/editorRegistry.ts +149 -0
  64. package/src/launcher/editors.ts +183 -0
  65. package/src/launcher/icons.ts +120 -20
  66. package/src/launcher/index.ts +62 -27
  67. package/src/launcher/registry.ts +33 -0
  68. package/src/launcher/tokens.ts +51 -0
  69. package/src/terminals/detection.ts +65 -0
  70. package/src/terminals/index.ts +222 -0
  71. package/src/terminals/model.ts +497 -0
  72. package/src/terminals/widget.tsx +240 -0
  73. package/src/topBar/icons.ts +30 -0
  74. package/src/topBar/index.ts +121 -0
  75. package/style/git.css +267 -266
  76. package/style/index.css +2 -1
  77. package/style/index.js +2 -1
  78. package/style/launcher.css +124 -2
  79. package/style/terminals.css +235 -0
  80. package/style/topBar.css +38 -0
  81. package/lib/git/icons.d.ts +0 -14
  82. package/lib/git/icons.js +0 -19
  83. package/lib/git/panel.d.ts +0 -29
  84. package/lib/git/panel.js +0 -42
  85. package/lib/git/panelComponent.d.ts +0 -28
  86. package/lib/git/panelComponent.js +0 -320
  87. package/lib/statusBar/index.d.ts +0 -14
  88. package/lib/statusBar/index.js +0 -89
  89. package/lib/statusBar/widget.d.ts +0 -121
  90. package/lib/statusBar/widget.js +0 -237
  91. package/src/git/icons.ts +0 -20
  92. package/src/git/panel.tsx +0 -56
  93. package/src/git/panelComponent.tsx +0 -575
  94. package/src/statusBar/index.ts +0 -115
  95. package/src/statusBar/widget.tsx +0 -318
  96. package/style/statusBar.css +0 -95
@@ -1,575 +0,0 @@
1
- import * as React from 'react';
2
-
3
- import { Poll } from '@lumino/polling';
4
- import { ISignal } from '@lumino/signaling';
5
-
6
- import {
7
- add,
8
- addAll,
9
- checkout,
10
- porcelainToFileChanges,
11
- reset,
12
- resetAll,
13
- showTopLevel,
14
- status
15
- } from './api';
16
- import { FileChangeStatus, IFileChange, IGitStatusResult } from './tokens';
17
-
18
- /**
19
- * Callbacks the panel needs to delegate to (so the React component stays
20
- * free of any JupyterLab application coupling). The plugin wires these up
21
- * with the actual `JupyterFrontEnd` reference.
22
- */
23
- export interface IGitPanelHandlers {
24
- /** Open a diff viewer for the given file in the main area. */
25
- openDiff(
26
- change: IFileChange,
27
- options?: {
28
- pin?: boolean;
29
- }
30
- ): void;
31
- /** Show a confirmation dialog. Returns true when the user accepted. */
32
- confirm(title: string, body: string, accept: string): Promise<boolean>;
33
- /** Show an error dialog. */
34
- showError(title: string, error: Error): Promise<void>;
35
- }
36
-
37
- interface IPanelState {
38
- /** True while the initial status request is in flight. */
39
- loading: boolean;
40
- /** The raw result of the last status call, or `null` if it hasn't completed yet. */
41
- result: IGitStatusResult | null;
42
- /** Set when the status request errored or the path is not in a repo. */
43
- error: string | null;
44
- }
45
-
46
- /**
47
- * The polling interval for the panel. We refresh the status periodically so
48
- * out-of-band changes (e.g. the user runs `git add` from a terminal) become
49
- * visible without requiring a manual refresh.
50
- */
51
- const STATUS_POLL_INTERVAL_MS = 5000;
52
-
53
- /**
54
- * Upper bound on the poll's exponential backoff when status requests fail
55
- * repeatedly. Matches the default JupyterLab file browser's `max`: long
56
- * enough that a server outage stops hammering the API, short enough that
57
- * a transient failure heals on its own.
58
- */
59
- const STATUS_POLL_MAX_MS = 300_000;
60
-
61
- /**
62
- * Top-level React component rendered inside the {@link GitPanel} widget.
63
- * Owns the polling + refresh wiring around the `/git/status` endpoint and
64
- * the small bit of UI state that controls section collapse and the
65
- * in-flight per-file action spinners.
66
- */
67
- export function GitPanelComponent(props: {
68
- handlers: IGitPanelHandlers;
69
- refreshSignal: ISignal<unknown, void>;
70
- }): React.ReactElement {
71
- const { handlers, refreshSignal } = props;
72
-
73
- const [state, setState] = React.useState<IPanelState>({
74
- loading: true,
75
- result: null,
76
- error: null
77
- });
78
- // The absolute filesystem path of the resolved git top-level. Used purely
79
- // for display in the panel header.
80
- const [topLevel, setTopLevel] = React.useState<string | null>(null);
81
- // Per-action spinner gating: keyed by `${group}:${path}`, value is the
82
- // action currently in flight. Lets the row render a disabled state on
83
- // exactly the file that is being mutated, without locking the whole panel.
84
- const [pendingActions, setPendingActions] = React.useState<
85
- Record<string, string>
86
- >({});
87
- const [stagedCollapsed, setStagedCollapsed] = React.useState(false);
88
- const [changesCollapsed, setChangesCollapsed] = React.useState(false);
89
-
90
- // The repo path passed to every API call. We use the JupyterLab server's
91
- // root (the empty string) and let `git` resolve the enclosing repo on the
92
- // server side. This works as long as the server was started inside a git
93
- // repo — the common case.
94
- const repoPath = '';
95
-
96
- const refresh = React.useCallback(async (): Promise<void> => {
97
- try {
98
- const [topLevelResult, statusResult] = await Promise.all([
99
- showTopLevel(repoPath),
100
- status(repoPath)
101
- ]);
102
- setTopLevel(topLevelResult);
103
- if (statusResult.code !== 0) {
104
- setState({
105
- loading: false,
106
- result: null,
107
- error: statusResult.message ?? 'Failed to load git status'
108
- });
109
- return;
110
- }
111
- setState({ loading: false, result: statusResult, error: null });
112
- } catch (err) {
113
- setState({
114
- loading: false,
115
- result: null,
116
- error: err instanceof Error ? err.message : String(err)
117
- });
118
- }
119
- }, [repoPath]);
120
-
121
- // Initial load + polling. Driven by a Lumino `Poll` so the panel stops
122
- // hitting the git endpoint while the JupyterLab tab is hidden, and so
123
- // repeated failures back off exponentially instead of spinning at 5s.
124
- // External refresh triggers (toolbar button, plugin-level events) call
125
- // `poll.refresh()` to schedule an immediate tick instead of running
126
- // `refresh()` directly — that keeps the panel from issuing two parallel
127
- // status requests when an automatic tick happens to land at the same
128
- // time as an external trigger.
129
- React.useEffect(() => {
130
- const poll = new Poll({
131
- name: '@xtralab/git:panelStatus',
132
- factory: () => refresh(),
133
- frequency: {
134
- interval: STATUS_POLL_INTERVAL_MS,
135
- backoff: true,
136
- max: STATUS_POLL_MAX_MS
137
- },
138
- standby: 'when-hidden'
139
- });
140
- const handler = (): void => {
141
- void poll.refresh();
142
- };
143
- refreshSignal.connect(handler);
144
- return () => {
145
- refreshSignal.disconnect(handler);
146
- poll.dispose();
147
- };
148
- }, [refresh, refreshSignal]);
149
-
150
- const runAction = React.useCallback(
151
- async (
152
- change: IFileChange,
153
- action: string,
154
- run: () => Promise<void>
155
- ): Promise<void> => {
156
- const key = `${change.group}:${change.path}`;
157
- setPendingActions(prev => ({ ...prev, [key]: action }));
158
- try {
159
- await run();
160
- await refresh();
161
- } catch (err) {
162
- await handlers.showError(
163
- 'Git operation failed',
164
- err instanceof Error ? err : new Error(String(err))
165
- );
166
- } finally {
167
- setPendingActions(prev => {
168
- const next = { ...prev };
169
- delete next[key];
170
- return next;
171
- });
172
- }
173
- },
174
- [handlers, refresh]
175
- );
176
-
177
- const onStage = React.useCallback(
178
- (change: IFileChange) =>
179
- runAction(change, 'stage', () => add(repoPath, change.path)),
180
- [repoPath, runAction]
181
- );
182
-
183
- const onUnstage = React.useCallback(
184
- (change: IFileChange) =>
185
- runAction(change, 'unstage', () => reset(repoPath, change.path)),
186
- [repoPath, runAction]
187
- );
188
-
189
- const onDiscard = React.useCallback(
190
- async (change: IFileChange): Promise<void> => {
191
- const verb = change.status === 'untracked' ? 'remove' : 'restore';
192
- const accepted = await handlers.confirm(
193
- verb === 'remove' ? 'Remove file' : 'Discard changes',
194
- verb === 'remove'
195
- ? `Permanently remove ${change.path}? This cannot be undone.`
196
- : `Discard all changes to ${change.path}? This will revert the file to its last staged version and cannot be undone.`,
197
- verb === 'remove' ? 'Remove' : 'Discard'
198
- );
199
- if (!accepted) {
200
- return;
201
- }
202
- // The git extension's `checkout` endpoint without a branch reverts the
203
- // working tree copy of the file to the index. For untracked files we
204
- // can't checkout, so the user is told the action is unsupported and we
205
- // bail out — they can delete the file via the file browser instead.
206
- if (change.status === 'untracked') {
207
- await handlers.showError(
208
- 'Cannot discard untracked file',
209
- new Error(
210
- 'Untracked files cannot be discarded from the git panel — delete them from the file browser instead.'
211
- )
212
- );
213
- return;
214
- }
215
- await runAction(change, 'discard', () => checkout(repoPath, change.path));
216
- },
217
- [handlers, repoPath, runAction]
218
- );
219
-
220
- const onStageAll = React.useCallback(async (): Promise<void> => {
221
- try {
222
- await addAll(repoPath);
223
- await refresh();
224
- } catch (err) {
225
- await handlers.showError(
226
- 'Stage all failed',
227
- err instanceof Error ? err : new Error(String(err))
228
- );
229
- }
230
- }, [handlers, refresh, repoPath]);
231
-
232
- const onUnstageAll = React.useCallback(async (): Promise<void> => {
233
- try {
234
- await resetAll(repoPath);
235
- await refresh();
236
- } catch (err) {
237
- await handlers.showError(
238
- 'Unstage all failed',
239
- err instanceof Error ? err : new Error(String(err))
240
- );
241
- }
242
- }, [handlers, refresh, repoPath]);
243
-
244
- // Categorize the porcelain entries into the two visible groups. Because
245
- // `porcelainToFileChanges` may emit two changes per file (one staged, one
246
- // unstaged), the same file can show up in both lists when it has been
247
- // partially staged.
248
- const { staged, unstaged } = React.useMemo(() => {
249
- const stagedAcc: IFileChange[] = [];
250
- const unstagedAcc: IFileChange[] = [];
251
- if (state.result === null) {
252
- return { staged: stagedAcc, unstaged: unstagedAcc };
253
- }
254
- for (const file of state.result.files) {
255
- for (const change of porcelainToFileChanges(file)) {
256
- if (change.group === 'staged') {
257
- stagedAcc.push(change);
258
- } else {
259
- unstagedAcc.push(change);
260
- }
261
- }
262
- }
263
- stagedAcc.sort((a, b) => a.path.localeCompare(b.path));
264
- unstagedAcc.sort((a, b) => a.path.localeCompare(b.path));
265
- return { staged: stagedAcc, unstaged: unstagedAcc };
266
- }, [state.result]);
267
-
268
- return (
269
- <div className="jp-xtralab-GitPanel-content">
270
- <PanelHeader
271
- topLevel={topLevel}
272
- result={state.result}
273
- loading={state.loading}
274
- error={state.error}
275
- onRefresh={() => void refresh()}
276
- />
277
- {state.error !== null ? (
278
- <div className="jp-xtralab-GitPanel-error">{state.error}</div>
279
- ) : (
280
- <>
281
- <ChangeSection
282
- label="Staged Changes"
283
- count={staged.length}
284
- collapsed={stagedCollapsed}
285
- onToggleCollapsed={() => setStagedCollapsed(c => !c)}
286
- actions={
287
- staged.length > 0
288
- ? [
289
- {
290
- label: 'Unstage all changes',
291
- symbol: '−',
292
- onClick: onUnstageAll
293
- }
294
- ]
295
- : undefined
296
- }
297
- >
298
- {staged.map(change => (
299
- <ChangeRow
300
- key={`${change.group}:${change.path}`}
301
- change={change}
302
- pendingAction={pendingActions[`${change.group}:${change.path}`]}
303
- onClick={event =>
304
- handlers.openDiff(change, {
305
- pin: event.ctrlKey || event.metaKey
306
- })
307
- }
308
- actions={[
309
- {
310
- label: 'Unstage changes',
311
- symbol: '−',
312
- onClick: () => void onUnstage(change)
313
- }
314
- ]}
315
- />
316
- ))}
317
- </ChangeSection>
318
- <ChangeSection
319
- label="Changes"
320
- count={unstaged.length}
321
- collapsed={changesCollapsed}
322
- onToggleCollapsed={() => setChangesCollapsed(c => !c)}
323
- actions={
324
- unstaged.length > 0
325
- ? [
326
- {
327
- label: 'Stage all changes',
328
- symbol: '+',
329
- onClick: onStageAll
330
- }
331
- ]
332
- : undefined
333
- }
334
- >
335
- {unstaged.map(change => (
336
- <ChangeRow
337
- key={`${change.group}:${change.path}`}
338
- change={change}
339
- pendingAction={pendingActions[`${change.group}:${change.path}`]}
340
- onClick={event =>
341
- handlers.openDiff(change, {
342
- pin: event.ctrlKey || event.metaKey
343
- })
344
- }
345
- actions={[
346
- {
347
- label: 'Discard changes',
348
- symbol: '⟲',
349
- onClick: () => void onDiscard(change)
350
- },
351
- {
352
- label: 'Stage changes',
353
- symbol: '+',
354
- onClick: () => void onStage(change)
355
- }
356
- ]}
357
- />
358
- ))}
359
- </ChangeSection>
360
- {staged.length === 0 && unstaged.length === 0 && !state.loading ? (
361
- <div className="jp-xtralab-GitPanel-empty">
362
- No changes detected.
363
- </div>
364
- ) : null}
365
- </>
366
- )}
367
- </div>
368
- );
369
- }
370
-
371
- interface IRowAction {
372
- label: string;
373
- symbol: string;
374
- onClick: () => void;
375
- }
376
-
377
- function PanelHeader(props: {
378
- topLevel: string | null;
379
- result: IGitStatusResult | null;
380
- loading: boolean;
381
- error: string | null;
382
- onRefresh: () => void;
383
- }): React.ReactElement {
384
- const { topLevel, result, loading, error, onRefresh } = props;
385
- const repoName =
386
- topLevel !== null
387
- ? (topLevel
388
- .split('/')
389
- .filter(s => s.length > 0)
390
- .pop() ?? topLevel)
391
- : null;
392
- return (
393
- <div className="jp-xtralab-GitPanel-header">
394
- <div className="jp-xtralab-GitPanel-headerInfo">
395
- {repoName !== null ? (
396
- <div className="jp-xtralab-GitPanel-repo" title={topLevel ?? ''}>
397
- {repoName}
398
- </div>
399
- ) : null}
400
- {result?.branch !== undefined && result?.branch !== null ? (
401
- <div className="jp-xtralab-GitPanel-branch">
402
- {result.branch}
403
- {result.ahead > 0 ? <span> ↑{result.ahead}</span> : null}
404
- {result.behind > 0 ? <span> ↓{result.behind}</span> : null}
405
- </div>
406
- ) : null}
407
- </div>
408
- <button
409
- type="button"
410
- className="jp-xtralab-GitPanel-iconButton"
411
- title="Refresh"
412
- disabled={loading && error === null}
413
- onClick={onRefresh}
414
- >
415
- ⟳
416
- </button>
417
- </div>
418
- );
419
- }
420
-
421
- function ChangeSection(props: {
422
- label: string;
423
- count: number;
424
- collapsed: boolean;
425
- onToggleCollapsed: () => void;
426
- actions?: IRowAction[];
427
- children: React.ReactNode;
428
- }): React.ReactElement {
429
- const { label, count, collapsed, onToggleCollapsed, actions, children } =
430
- props;
431
- return (
432
- <div className="jp-xtralab-GitPanel-section">
433
- <div
434
- className="jp-xtralab-GitPanel-sectionHeader"
435
- onClick={onToggleCollapsed}
436
- >
437
- <span className="jp-xtralab-GitPanel-sectionChevron">
438
- {collapsed ? '▸' : '▾'}
439
- </span>
440
- <span className="jp-xtralab-GitPanel-sectionLabel">{label}</span>
441
- {actions !== undefined ? (
442
- <span className="jp-xtralab-GitPanel-sectionActions">
443
- {actions.map(action => (
444
- <button
445
- key={action.label}
446
- type="button"
447
- className="jp-xtralab-GitPanel-iconButton"
448
- title={action.label}
449
- onClick={event => {
450
- event.stopPropagation();
451
- action.onClick();
452
- }}
453
- >
454
- {action.symbol}
455
- </button>
456
- ))}
457
- </span>
458
- ) : null}
459
- <span className="jp-xtralab-GitPanel-sectionCount">{count}</span>
460
- </div>
461
- {!collapsed ? (
462
- <div className="jp-xtralab-GitPanel-sectionBody">{children}</div>
463
- ) : null}
464
- </div>
465
- );
466
- }
467
-
468
- function ChangeRow(props: {
469
- change: IFileChange;
470
- pendingAction: string | undefined;
471
- onClick: (event: React.MouseEvent<HTMLDivElement>) => void;
472
- actions: IRowAction[];
473
- }): React.ReactElement {
474
- const { change, pendingAction, onClick, actions } = props;
475
- const filename = basename(change.path);
476
- const dirname = dirnameOrEmpty(change.path);
477
- return (
478
- <div
479
- className="jp-xtralab-GitPanel-row"
480
- data-status={change.status}
481
- title={
482
- change.from !== undefined
483
- ? `${change.from} → ${change.path}`
484
- : change.path
485
- }
486
- onClick={onClick}
487
- >
488
- <span className="jp-xtralab-GitPanel-rowLabel">
489
- <span className="jp-xtralab-GitPanel-rowName">{filename}</span>
490
- {dirname !== '' ? (
491
- <span className="jp-xtralab-GitPanel-rowPath">{dirname}</span>
492
- ) : null}
493
- </span>
494
- <span
495
- className="jp-xtralab-GitPanel-rowActions"
496
- data-pending={pendingAction !== undefined}
497
- >
498
- {actions.map(action => (
499
- <button
500
- key={action.label}
501
- type="button"
502
- className="jp-xtralab-GitPanel-iconButton"
503
- title={action.label}
504
- disabled={pendingAction !== undefined}
505
- onClick={event => {
506
- event.stopPropagation();
507
- action.onClick();
508
- }}
509
- >
510
- {action.symbol}
511
- </button>
512
- ))}
513
- </span>
514
- <span
515
- className="jp-xtralab-GitPanel-rowBadge"
516
- data-status={change.status}
517
- title={statusTooltip(change.status)}
518
- >
519
- {statusBadge(change.status)}
520
- </span>
521
- </div>
522
- );
523
- }
524
-
525
- function statusBadge(status: FileChangeStatus): string {
526
- switch (status) {
527
- case 'modified':
528
- return 'M';
529
- case 'added':
530
- return 'A';
531
- case 'deleted':
532
- return 'D';
533
- case 'renamed':
534
- return 'R';
535
- case 'untracked':
536
- return 'U';
537
- case 'unmerged':
538
- return '!';
539
- case 'typechange':
540
- return 'T';
541
- default:
542
- return '?';
543
- }
544
- }
545
-
546
- function statusTooltip(status: FileChangeStatus): string {
547
- switch (status) {
548
- case 'modified':
549
- return 'Modified';
550
- case 'added':
551
- return 'Added';
552
- case 'deleted':
553
- return 'Deleted';
554
- case 'renamed':
555
- return 'Renamed';
556
- case 'untracked':
557
- return 'Untracked';
558
- case 'unmerged':
559
- return 'Unmerged (conflict)';
560
- case 'typechange':
561
- return 'Type changed';
562
- default:
563
- return 'Unknown';
564
- }
565
- }
566
-
567
- function basename(path: string): string {
568
- const idx = path.lastIndexOf('/');
569
- return idx < 0 ? path : path.slice(idx + 1);
570
- }
571
-
572
- function dirnameOrEmpty(path: string): string {
573
- const idx = path.lastIndexOf('/');
574
- return idx < 0 ? '' : path.slice(0, idx);
575
- }
@@ -1,115 +0,0 @@
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;