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.
- package/README.md +86 -0
- package/lib/fileBrowser/fileBrowser.js +71 -1
- package/lib/fileBrowser/gitignore.d.ts +29 -0
- package/lib/fileBrowser/gitignore.js +74 -0
- package/lib/fileBrowser/index.js +1 -1
- package/lib/git/api.d.ts +11 -0
- package/lib/git/api.js +19 -0
- package/lib/git/commands.d.ts +3 -1
- package/lib/git/commands.js +10 -4
- package/lib/git/diffWidget.d.ts +11 -0
- package/lib/git/diffWidget.js +90 -9
- package/lib/git/index.js +23 -7
- package/lib/git/panelComponent.d.ts +3 -1
- package/lib/git/panelComponent.js +6 -3
- package/lib/index.js +9 -1
- package/lib/launcher/agents.d.ts +78 -0
- package/lib/launcher/agents.js +168 -0
- package/lib/launcher/availability.d.ts +12 -0
- package/lib/launcher/availability.js +53 -0
- package/lib/launcher/commands.d.ts +22 -0
- package/lib/launcher/commands.js +89 -0
- package/lib/launcher/dashboard.d.ts +46 -0
- package/lib/launcher/dashboard.js +164 -0
- package/lib/launcher/icons.d.ts +33 -0
- package/lib/launcher/icons.js +113 -0
- package/lib/launcher/index.d.ts +23 -0
- package/lib/launcher/index.js +191 -0
- package/lib/launcher/invocation.d.ts +29 -0
- package/lib/launcher/invocation.js +42 -0
- package/lib/menus/index.d.ts +14 -0
- package/lib/menus/index.js +102 -0
- package/lib/sidebar/index.d.ts +18 -0
- package/lib/sidebar/index.js +155 -0
- package/lib/statusBar/index.d.ts +14 -0
- package/lib/statusBar/index.js +89 -0
- package/lib/statusBar/widget.d.ts +121 -0
- package/lib/statusBar/widget.js +237 -0
- package/package.json +10 -2
- package/schema/launcher.json +70 -0
- package/schema/sidebar.json +57 -0
- package/src/fileBrowser/fileBrowser.tsx +75 -1
- package/src/fileBrowser/gitignore.ts +86 -0
- package/src/fileBrowser/index.ts +1 -1
- package/src/git/api.ts +20 -0
- package/src/git/commands.ts +19 -5
- package/src/git/diffWidget.tsx +109 -13
- package/src/git/index.ts +32 -6
- package/src/git/panelComponent.tsx +17 -5
- package/src/index.ts +9 -1
- package/src/launcher/agents.ts +248 -0
- package/src/launcher/availability.ts +73 -0
- package/src/launcher/commands.ts +103 -0
- package/src/launcher/dashboard.tsx +356 -0
- package/src/launcher/icons.ts +123 -0
- package/src/launcher/index.ts +228 -0
- package/src/launcher/invocation.ts +45 -0
- package/src/menus/index.ts +124 -0
- package/src/sidebar/index.ts +195 -0
- package/src/statusBar/index.ts +115 -0
- package/src/statusBar/widget.tsx +318 -0
- package/style/git.css +4 -8
- package/style/index.css +2 -0
- package/style/index.js +2 -0
- package/style/launcher.css +323 -0
- package/style/statusBar.css +95 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { MainAreaWidget } from '@jupyterlab/apputils';
|
|
2
|
+
import type { ServiceManager } from '@jupyterlab/services';
|
|
3
|
+
import type { ITerminal, ITerminalTracker } from '@jupyterlab/terminal';
|
|
4
|
+
import { type TranslationBundle } from '@jupyterlab/translation';
|
|
5
|
+
import { VDomModel, VDomRenderer } from '@jupyterlab/ui-components';
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
/**
|
|
8
|
+
* The widget shape every entry in `ITerminalTracker` takes — kept here
|
|
9
|
+
* so the registry's call sites read cleanly.
|
|
10
|
+
*/
|
|
11
|
+
export type TerminalWidget = MainAreaWidget<ITerminal.ITerminal>;
|
|
12
|
+
/**
|
|
13
|
+
* Source-of-truth model for the status bar. Each running terminal
|
|
14
|
+
* session known to the server is one entry; the registry caches the
|
|
15
|
+
* last `widget.title.label` we observed so the agent name (or any
|
|
16
|
+
* xterm-published title) survives the user closing the tab while the
|
|
17
|
+
* session continues running on the backend.
|
|
18
|
+
*
|
|
19
|
+
* Two upstream signals feed the model:
|
|
20
|
+
* - `serviceManager.terminals.runningChanged` is the authoritative
|
|
21
|
+
* list of live sessions; everything not in it has been shut down
|
|
22
|
+
* server-side and we must drop it.
|
|
23
|
+
* - `tracker.widgetAdded` plus the per-widget `title.changed` /
|
|
24
|
+
* `disposed` signals keep our label cache in sync with whatever
|
|
25
|
+
* xterm/the launcher has set on the open tabs.
|
|
26
|
+
*
|
|
27
|
+
* `stateChanged` is reused for both kinds of update so consumers
|
|
28
|
+
* (the per-session status item, the plugin's add/remove sync) can
|
|
29
|
+
* subscribe once.
|
|
30
|
+
*/
|
|
31
|
+
export declare class SessionRegistry extends VDomModel {
|
|
32
|
+
constructor(serviceManager: ServiceManager.IManager, tracker: ITerminalTracker);
|
|
33
|
+
/**
|
|
34
|
+
* Names of all live sessions, ordered by their stable rank so the
|
|
35
|
+
* status bar items keep their slots as new sessions are added.
|
|
36
|
+
*/
|
|
37
|
+
sessionNames(): string[];
|
|
38
|
+
/**
|
|
39
|
+
* True iff the named session is still on the server. Used by the
|
|
40
|
+
* per-session item to render `null` during the brief window between
|
|
41
|
+
* the session's shutdown and the plugin disposing the item.
|
|
42
|
+
*/
|
|
43
|
+
has(name: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Display name for the session. The cache wins because it only
|
|
46
|
+
* holds "real" labels (the launcher's agent name or an xterm escape
|
|
47
|
+
* sequence — see `_cacheLabel` for the filter); reading it first
|
|
48
|
+
* prevents the transient `Terminal {name}` an XTerm widget shows
|
|
49
|
+
* during reconnect from flickering into the status bar. The live
|
|
50
|
+
* widget title is used when no cache exists, and the
|
|
51
|
+
* `Terminal {name}` fallback covers sessions we have never seen a
|
|
52
|
+
* widget for (e.g. surviving a lab reload).
|
|
53
|
+
*/
|
|
54
|
+
labelFor(name: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Return the open widget for a session, if any. The status item
|
|
57
|
+
* uses this to switch behaviour between "activate existing tab"
|
|
58
|
+
* and "open a new tab connected to the live session".
|
|
59
|
+
*/
|
|
60
|
+
widgetFor(name: string): TerminalWidget | null;
|
|
61
|
+
/**
|
|
62
|
+
* Stable per-session rank assigned in observation order. Reused
|
|
63
|
+
* across `registerStatusItem` calls so an item that is briefly
|
|
64
|
+
* unregistered (e.g. while a widget is being recreated) lands back
|
|
65
|
+
* in the same slot. Cleaned up when the session is shut down so the
|
|
66
|
+
* counter does not grow without bound across long sessions.
|
|
67
|
+
*/
|
|
68
|
+
rankFor(name: string): number;
|
|
69
|
+
dispose(): void;
|
|
70
|
+
private _refreshLive;
|
|
71
|
+
private _onRunningChanged;
|
|
72
|
+
private _onWidgetAdded;
|
|
73
|
+
private _trackWidget;
|
|
74
|
+
private _untrackWidget;
|
|
75
|
+
private _onTitleChanged;
|
|
76
|
+
/**
|
|
77
|
+
* Update the cached label for a session, but only when the new
|
|
78
|
+
* label is "real" — non-empty and not one of the transient defaults
|
|
79
|
+
* the XTerm widget cycles through during reconnect (`'...'` while
|
|
80
|
+
* the websocket is opening, `'Terminal {name}'` once
|
|
81
|
+
* `_initialConnection` fires). Without the filter, briefly
|
|
82
|
+
* reopening a tab while an agent has yet to re-emit its xterm
|
|
83
|
+
* title escape sequence would clobber the cached agent name with
|
|
84
|
+
* `Terminal 1`, which is exactly the regression we are guarding
|
|
85
|
+
* against. Live widget titles still display whatever the widget
|
|
86
|
+
* currently holds because `labelFor` consults the widget first;
|
|
87
|
+
* the filter only affects what survives a tab close.
|
|
88
|
+
*/
|
|
89
|
+
private _cacheLabel;
|
|
90
|
+
private _onWidgetDisposed;
|
|
91
|
+
private _terminals;
|
|
92
|
+
private _tracker;
|
|
93
|
+
private _labels;
|
|
94
|
+
private _ranks;
|
|
95
|
+
private _live;
|
|
96
|
+
private _nextRank;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* One status bar item per live terminal session. Renders a terminal
|
|
100
|
+
* icon plus the session's label, with an inline shutdown button that
|
|
101
|
+
* appears on hover/focus. The item delegates click handling to the
|
|
102
|
+
* plugin so the lifecycle (create/dispose) and the user-facing
|
|
103
|
+
* behaviour (activate vs. reopen, shutdown) live in one place.
|
|
104
|
+
*/
|
|
105
|
+
export declare class TerminalSessionItem extends VDomRenderer<SessionRegistry> {
|
|
106
|
+
constructor(options: TerminalSessionItem.IOptions);
|
|
107
|
+
render(): React.ReactElement | null;
|
|
108
|
+
private _sessionName;
|
|
109
|
+
private _trans;
|
|
110
|
+
private _onActivate;
|
|
111
|
+
private _onShutdown;
|
|
112
|
+
}
|
|
113
|
+
export declare namespace TerminalSessionItem {
|
|
114
|
+
interface IOptions {
|
|
115
|
+
registry: SessionRegistry;
|
|
116
|
+
sessionName: string;
|
|
117
|
+
trans?: TranslationBundle;
|
|
118
|
+
onActivate: (sessionName: string) => void;
|
|
119
|
+
onShutdown: (sessionName: string) => void;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { nullTranslator } from '@jupyterlab/translation';
|
|
2
|
+
import { closeIcon, terminalIcon, VDomModel, VDomRenderer } from '@jupyterlab/ui-components';
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
/**
|
|
5
|
+
* Source-of-truth model for the status bar. Each running terminal
|
|
6
|
+
* session known to the server is one entry; the registry caches the
|
|
7
|
+
* last `widget.title.label` we observed so the agent name (or any
|
|
8
|
+
* xterm-published title) survives the user closing the tab while the
|
|
9
|
+
* session continues running on the backend.
|
|
10
|
+
*
|
|
11
|
+
* Two upstream signals feed the model:
|
|
12
|
+
* - `serviceManager.terminals.runningChanged` is the authoritative
|
|
13
|
+
* list of live sessions; everything not in it has been shut down
|
|
14
|
+
* server-side and we must drop it.
|
|
15
|
+
* - `tracker.widgetAdded` plus the per-widget `title.changed` /
|
|
16
|
+
* `disposed` signals keep our label cache in sync with whatever
|
|
17
|
+
* xterm/the launcher has set on the open tabs.
|
|
18
|
+
*
|
|
19
|
+
* `stateChanged` is reused for both kinds of update so consumers
|
|
20
|
+
* (the per-session status item, the plugin's add/remove sync) can
|
|
21
|
+
* subscribe once.
|
|
22
|
+
*/
|
|
23
|
+
export class SessionRegistry extends VDomModel {
|
|
24
|
+
constructor(serviceManager, tracker) {
|
|
25
|
+
super();
|
|
26
|
+
this._labels = new Map();
|
|
27
|
+
this._ranks = new Map();
|
|
28
|
+
this._live = new Set();
|
|
29
|
+
this._nextRank = 100;
|
|
30
|
+
this._terminals = serviceManager.terminals;
|
|
31
|
+
this._tracker = tracker;
|
|
32
|
+
this._terminals.runningChanged.connect(this._onRunningChanged, this);
|
|
33
|
+
this._tracker.widgetAdded.connect(this._onWidgetAdded, this);
|
|
34
|
+
this._tracker.forEach(widget => this._trackWidget(widget));
|
|
35
|
+
this._refreshLive();
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Names of all live sessions, ordered by their stable rank so the
|
|
39
|
+
* status bar items keep their slots as new sessions are added.
|
|
40
|
+
*/
|
|
41
|
+
sessionNames() {
|
|
42
|
+
const names = Array.from(this._live);
|
|
43
|
+
names.sort((a, b) => this.rankFor(a) - this.rankFor(b));
|
|
44
|
+
return names;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* True iff the named session is still on the server. Used by the
|
|
48
|
+
* per-session item to render `null` during the brief window between
|
|
49
|
+
* the session's shutdown and the plugin disposing the item.
|
|
50
|
+
*/
|
|
51
|
+
has(name) {
|
|
52
|
+
return this._live.has(name);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Display name for the session. The cache wins because it only
|
|
56
|
+
* holds "real" labels (the launcher's agent name or an xterm escape
|
|
57
|
+
* sequence — see `_cacheLabel` for the filter); reading it first
|
|
58
|
+
* prevents the transient `Terminal {name}` an XTerm widget shows
|
|
59
|
+
* during reconnect from flickering into the status bar. The live
|
|
60
|
+
* widget title is used when no cache exists, and the
|
|
61
|
+
* `Terminal {name}` fallback covers sessions we have never seen a
|
|
62
|
+
* widget for (e.g. surviving a lab reload).
|
|
63
|
+
*/
|
|
64
|
+
labelFor(name) {
|
|
65
|
+
const cached = this._labels.get(name);
|
|
66
|
+
if (cached) {
|
|
67
|
+
return cached;
|
|
68
|
+
}
|
|
69
|
+
const widget = this.widgetFor(name);
|
|
70
|
+
if (widget && widget.title.label) {
|
|
71
|
+
return widget.title.label;
|
|
72
|
+
}
|
|
73
|
+
return `Terminal ${name}`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Return the open widget for a session, if any. The status item
|
|
77
|
+
* uses this to switch behaviour between "activate existing tab"
|
|
78
|
+
* and "open a new tab connected to the live session".
|
|
79
|
+
*/
|
|
80
|
+
widgetFor(name) {
|
|
81
|
+
var _a;
|
|
82
|
+
return ((_a = this._tracker.find(widget => widget.content.session.name === name)) !== null && _a !== void 0 ? _a : null);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Stable per-session rank assigned in observation order. Reused
|
|
86
|
+
* across `registerStatusItem` calls so an item that is briefly
|
|
87
|
+
* unregistered (e.g. while a widget is being recreated) lands back
|
|
88
|
+
* in the same slot. Cleaned up when the session is shut down so the
|
|
89
|
+
* counter does not grow without bound across long sessions.
|
|
90
|
+
*/
|
|
91
|
+
rankFor(name) {
|
|
92
|
+
let rank = this._ranks.get(name);
|
|
93
|
+
if (rank === undefined) {
|
|
94
|
+
rank = this._nextRank++;
|
|
95
|
+
this._ranks.set(name, rank);
|
|
96
|
+
}
|
|
97
|
+
return rank;
|
|
98
|
+
}
|
|
99
|
+
dispose() {
|
|
100
|
+
if (this.isDisposed) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this._terminals.runningChanged.disconnect(this._onRunningChanged, this);
|
|
104
|
+
this._tracker.widgetAdded.disconnect(this._onWidgetAdded, this);
|
|
105
|
+
this._tracker.forEach(widget => this._untrackWidget(widget));
|
|
106
|
+
super.dispose();
|
|
107
|
+
}
|
|
108
|
+
_refreshLive() {
|
|
109
|
+
const next = new Set();
|
|
110
|
+
for (const model of this._terminals.running()) {
|
|
111
|
+
next.add(model.name);
|
|
112
|
+
}
|
|
113
|
+
this._live = next;
|
|
114
|
+
// Drop cache + rank entries for sessions that have been shut down
|
|
115
|
+
// server-side. Without this prune the rank counter would keep
|
|
116
|
+
// climbing across long-running labs as terminals come and go.
|
|
117
|
+
for (const name of Array.from(this._labels.keys())) {
|
|
118
|
+
if (!next.has(name)) {
|
|
119
|
+
this._labels.delete(name);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const name of Array.from(this._ranks.keys())) {
|
|
123
|
+
if (!next.has(name)) {
|
|
124
|
+
this._ranks.delete(name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
_onRunningChanged(_, sessions) {
|
|
129
|
+
this._live = new Set(sessions.map(model => model.name));
|
|
130
|
+
for (const name of Array.from(this._labels.keys())) {
|
|
131
|
+
if (!this._live.has(name)) {
|
|
132
|
+
this._labels.delete(name);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const name of Array.from(this._ranks.keys())) {
|
|
136
|
+
if (!this._live.has(name)) {
|
|
137
|
+
this._ranks.delete(name);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.stateChanged.emit();
|
|
141
|
+
}
|
|
142
|
+
_onWidgetAdded(_, widget) {
|
|
143
|
+
this._trackWidget(widget);
|
|
144
|
+
this.stateChanged.emit();
|
|
145
|
+
}
|
|
146
|
+
_trackWidget(widget) {
|
|
147
|
+
const name = widget.content.session.name;
|
|
148
|
+
this._cacheLabel(name, widget.title.label);
|
|
149
|
+
widget.title.changed.connect(this._onTitleChanged, this);
|
|
150
|
+
widget.disposed.connect(this._onWidgetDisposed, this);
|
|
151
|
+
}
|
|
152
|
+
_untrackWidget(widget) {
|
|
153
|
+
widget.title.changed.disconnect(this._onTitleChanged, this);
|
|
154
|
+
widget.disposed.disconnect(this._onWidgetDisposed, this);
|
|
155
|
+
}
|
|
156
|
+
_onTitleChanged(title) {
|
|
157
|
+
var _a;
|
|
158
|
+
const owner = title.owner;
|
|
159
|
+
const session = (_a = owner.content) === null || _a === void 0 ? void 0 : _a.session;
|
|
160
|
+
if (!session) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this._cacheLabel(session.name, title.label);
|
|
164
|
+
this.stateChanged.emit();
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Update the cached label for a session, but only when the new
|
|
168
|
+
* label is "real" — non-empty and not one of the transient defaults
|
|
169
|
+
* the XTerm widget cycles through during reconnect (`'...'` while
|
|
170
|
+
* the websocket is opening, `'Terminal {name}'` once
|
|
171
|
+
* `_initialConnection` fires). Without the filter, briefly
|
|
172
|
+
* reopening a tab while an agent has yet to re-emit its xterm
|
|
173
|
+
* title escape sequence would clobber the cached agent name with
|
|
174
|
+
* `Terminal 1`, which is exactly the regression we are guarding
|
|
175
|
+
* against. Live widget titles still display whatever the widget
|
|
176
|
+
* currently holds because `labelFor` consults the widget first;
|
|
177
|
+
* the filter only affects what survives a tab close.
|
|
178
|
+
*/
|
|
179
|
+
_cacheLabel(name, label) {
|
|
180
|
+
if (!label) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (label === '...' || label === `Terminal ${name}`) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this._labels.set(name, label);
|
|
187
|
+
}
|
|
188
|
+
_onWidgetDisposed(widget) {
|
|
189
|
+
// The widget is gone but the session may still be running on the
|
|
190
|
+
// server — keep the cached label so the status item keeps the
|
|
191
|
+
// agent's name when the user reopens the tab. We only clean up
|
|
192
|
+
// our subscriptions here; the cache is purged by `runningChanged`
|
|
193
|
+
// once the session itself goes away.
|
|
194
|
+
this._untrackWidget(widget);
|
|
195
|
+
this.stateChanged.emit();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* One status bar item per live terminal session. Renders a terminal
|
|
200
|
+
* icon plus the session's label, with an inline shutdown button that
|
|
201
|
+
* appears on hover/focus. The item delegates click handling to the
|
|
202
|
+
* plugin so the lifecycle (create/dispose) and the user-facing
|
|
203
|
+
* behaviour (activate vs. reopen, shutdown) live in one place.
|
|
204
|
+
*/
|
|
205
|
+
export class TerminalSessionItem extends VDomRenderer {
|
|
206
|
+
constructor(options) {
|
|
207
|
+
var _a;
|
|
208
|
+
super(options.registry);
|
|
209
|
+
this._sessionName = options.sessionName;
|
|
210
|
+
this._trans = (_a = options.trans) !== null && _a !== void 0 ? _a : nullTranslator.load('jupyterlab');
|
|
211
|
+
this._onActivate = options.onActivate;
|
|
212
|
+
this._onShutdown = options.onShutdown;
|
|
213
|
+
this.addClass('jp-mod-highlighted');
|
|
214
|
+
this.addClass('jp-xtralab-StatusBar-Terminal');
|
|
215
|
+
}
|
|
216
|
+
render() {
|
|
217
|
+
if (!this.model || !this.model.has(this._sessionName)) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const trans = this._trans;
|
|
221
|
+
const label = this.model.labelFor(this._sessionName);
|
|
222
|
+
const hasWidget = this.model.widgetFor(this._sessionName) !== null;
|
|
223
|
+
const tooltip = hasWidget
|
|
224
|
+
? trans.__('Activate %1', label)
|
|
225
|
+
: trans.__('Reopen %1', label);
|
|
226
|
+
this.node.title = tooltip;
|
|
227
|
+
return (React.createElement("div", { className: "jp-xtralab-StatusBar-Terminal-Container" },
|
|
228
|
+
React.createElement("button", { type: "button", className: "jp-xtralab-StatusBar-Terminal-Activate", onClick: () => this._onActivate(this._sessionName), "aria-label": tooltip },
|
|
229
|
+
React.createElement(terminalIcon.react, { tag: "span", verticalAlign: "middle", stylesheet: "statusBar" }),
|
|
230
|
+
React.createElement("span", { className: "jp-xtralab-StatusBar-Terminal-Label" }, label)),
|
|
231
|
+
React.createElement("button", { type: "button", className: "jp-xtralab-StatusBar-Terminal-Close", onClick: event => {
|
|
232
|
+
event.stopPropagation();
|
|
233
|
+
this._onShutdown(this._sessionName);
|
|
234
|
+
}, title: trans.__('Shut down %1', label), "aria-label": trans.__('Shut down %1', label) },
|
|
235
|
+
React.createElement(closeIcon.react, { tag: "span", verticalAlign: "middle", stylesheet: "statusBar" }))));
|
|
236
|
+
}
|
|
237
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xtralab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "An opinionated JupyterLab meta-package that bundles a curated set of extensions, ships a path-first file browser and a VS Code-style git changes panel, and applies a quieter default workspace configuration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -62,12 +62,18 @@
|
|
|
62
62
|
"@jupyterlab/coreutils": "^6.6.0-alpha.5",
|
|
63
63
|
"@jupyterlab/docmanager": "^4.6.0-alpha.5",
|
|
64
64
|
"@jupyterlab/docregistry": "^4.6.0-alpha.5",
|
|
65
|
+
"@jupyterlab/launcher": "^4.6.0-alpha.5",
|
|
66
|
+
"@jupyterlab/mainmenu": "^4.6.0-alpha.5",
|
|
65
67
|
"@jupyterlab/nbformat": "^4.6.0-alpha.5",
|
|
66
68
|
"@jupyterlab/outputarea": "^4.6.0-alpha.5",
|
|
67
69
|
"@jupyterlab/rendermime": "^4.6.0-alpha.5",
|
|
68
70
|
"@jupyterlab/services": "^7.6.0-alpha.5",
|
|
69
71
|
"@jupyterlab/settingregistry": "^4.6.0-alpha.5",
|
|
72
|
+
"@jupyterlab/statusbar": "^4.6.0-alpha.5",
|
|
73
|
+
"@jupyterlab/terminal": "^4.6.0-alpha.5",
|
|
74
|
+
"@jupyterlab/translation": "^4.6.0-alpha.5",
|
|
70
75
|
"@jupyterlab/ui-components": "^4.6.0-alpha.5",
|
|
76
|
+
"@lumino/algorithm": "^2.0.0",
|
|
71
77
|
"@lumino/coreutils": "^2.0.0",
|
|
72
78
|
"@lumino/disposable": "^2.0.0",
|
|
73
79
|
"@lumino/dragdrop": "^2.0.0",
|
|
@@ -76,6 +82,7 @@
|
|
|
76
82
|
"@lumino/widgets": "^2.0.0",
|
|
77
83
|
"@pierre/diffs": "^1.1.20",
|
|
78
84
|
"@pierre/trees": "1.0.0-beta.3",
|
|
85
|
+
"ignore": "^5.3.2",
|
|
79
86
|
"react": "^18.2.0",
|
|
80
87
|
"react-dom": "^18.2.0"
|
|
81
88
|
},
|
|
@@ -119,7 +126,8 @@
|
|
|
119
126
|
"outputDir": "xtralab/labextension",
|
|
120
127
|
"schemaDir": "schema",
|
|
121
128
|
"disabledExtensions": [
|
|
122
|
-
"@jupyterlab/git"
|
|
129
|
+
"@jupyterlab/git",
|
|
130
|
+
"@jupyterlab/launcher-extension"
|
|
123
131
|
]
|
|
124
132
|
},
|
|
125
133
|
"eslintIgnore": [
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "xtralab launcher",
|
|
3
|
+
"description": "Settings for the xtralab agent launcher.",
|
|
4
|
+
"jupyter.lab.shortcuts": [],
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"agents": {
|
|
9
|
+
"title": "Agents",
|
|
10
|
+
"description": "Override or extend the built-in list of agents shown on the launcher. Each entry must include an `id`. When the id matches a built-in (claude, codex, gemini, copilot, goose, opencode, kiro, mistral-vibe), the fields you set override the corresponding defaults; leave a field out to keep the default. New ids define brand-new cards. Set `enabled: false` to hide a default. Set `requireAvailable: false` for shell aliases that should always show even though they aren't on PATH.",
|
|
11
|
+
"type": "array",
|
|
12
|
+
"default": [],
|
|
13
|
+
"items": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"additionalProperties": false,
|
|
16
|
+
"required": ["id"],
|
|
17
|
+
"properties": {
|
|
18
|
+
"id": {
|
|
19
|
+
"title": "ID",
|
|
20
|
+
"description": "Stable identifier. Match one of xtralab's defaults to override, or pick a new id to define a new card.",
|
|
21
|
+
"type": "string"
|
|
22
|
+
},
|
|
23
|
+
"label": {
|
|
24
|
+
"title": "Label",
|
|
25
|
+
"description": "Card title shown on the launcher.",
|
|
26
|
+
"type": "string"
|
|
27
|
+
},
|
|
28
|
+
"caption": {
|
|
29
|
+
"title": "Caption",
|
|
30
|
+
"description": "Short description shown beneath the card title.",
|
|
31
|
+
"type": "string"
|
|
32
|
+
},
|
|
33
|
+
"command": {
|
|
34
|
+
"title": "Command",
|
|
35
|
+
"description": "Literal string typed into the new terminal. Aliases work because shells expand them on interactive lines.",
|
|
36
|
+
"type": "string"
|
|
37
|
+
},
|
|
38
|
+
"iconSvg": {
|
|
39
|
+
"title": "Icon SVG",
|
|
40
|
+
"description": "Inline SVG for the card icon. Required only for new agents whose icon is not already shipped by xtralab.",
|
|
41
|
+
"type": "string"
|
|
42
|
+
},
|
|
43
|
+
"rank": {
|
|
44
|
+
"title": "Rank",
|
|
45
|
+
"description": "Sort order on the launcher (lower numbers come first).",
|
|
46
|
+
"type": "number"
|
|
47
|
+
},
|
|
48
|
+
"enabled": {
|
|
49
|
+
"title": "Enabled",
|
|
50
|
+
"description": "When false, hides the agent from the launcher and command palette.",
|
|
51
|
+
"type": "boolean",
|
|
52
|
+
"default": true
|
|
53
|
+
},
|
|
54
|
+
"requireAvailable": {
|
|
55
|
+
"title": "Require available",
|
|
56
|
+
"description": "When false, the launcher skips the availability check for this entry (useful for shell aliases that aren't on PATH but resolve in an interactive shell).",
|
|
57
|
+
"type": "boolean",
|
|
58
|
+
"default": true
|
|
59
|
+
},
|
|
60
|
+
"promptArgs": {
|
|
61
|
+
"title": "Prompt arguments",
|
|
62
|
+
"description": "How to splice an initial prompt from the launcher textarea into the agent's command line. Empty array (`[]`) appends the prompt as a positional argument. An array like `[\"-i\"]` or `[\"--prompt\"]` prefixes the prompt with that flag. Set to `null` to opt out of prompt support entirely (the agent's button is dimmed when the prompt textarea is non-empty).",
|
|
63
|
+
"type": ["array", "null"],
|
|
64
|
+
"items": { "type": "string" }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "xtralab sidebar",
|
|
3
|
+
"description": "Visibility of selected JupyterLab sidebar tabs.",
|
|
4
|
+
"jupyter.lab.shortcuts": [],
|
|
5
|
+
"jupyter.lab.menus": {
|
|
6
|
+
"main": [
|
|
7
|
+
{
|
|
8
|
+
"id": "jp-mainmenu-view",
|
|
9
|
+
"items": [
|
|
10
|
+
{
|
|
11
|
+
"type": "submenu",
|
|
12
|
+
"rank": 1,
|
|
13
|
+
"submenu": {
|
|
14
|
+
"id": "jp-mainmenu-view-appearance",
|
|
15
|
+
"items": [
|
|
16
|
+
{
|
|
17
|
+
"type": "submenu",
|
|
18
|
+
"rank": 12.5,
|
|
19
|
+
"submenu": {
|
|
20
|
+
"id": "jp-mainmenu-view-appearance-left-sidebar",
|
|
21
|
+
"label": "Left Sidebar",
|
|
22
|
+
"items": [
|
|
23
|
+
{
|
|
24
|
+
"command": "xtralab:toggle-default-filebrowser",
|
|
25
|
+
"rank": 1
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"command": "xtralab:toggle-running-sessions",
|
|
29
|
+
"rank": 2
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": {
|
|
43
|
+
"showDefaultFileBrowser": {
|
|
44
|
+
"type": "boolean",
|
|
45
|
+
"title": "Show the default JupyterLab file browser",
|
|
46
|
+
"description": "When false, hides the default `filebrowser` tab from the left sidebar. The xtralab file browser is unaffected.",
|
|
47
|
+
"default": true
|
|
48
|
+
},
|
|
49
|
+
"showRunningSessions": {
|
|
50
|
+
"type": "boolean",
|
|
51
|
+
"title": "Show running terminals and kernels",
|
|
52
|
+
"description": "When false, hides the `jp-running-sessions` (Running Terminals and Kernels) tab from the left sidebar.",
|
|
53
|
+
"default": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"additionalProperties": false
|
|
57
|
+
}
|
|
@@ -12,7 +12,10 @@ import type {
|
|
|
12
12
|
FileTreeDirectoryHandle
|
|
13
13
|
} from '@pierre/trees';
|
|
14
14
|
|
|
15
|
+
import type { Ignore } from 'ignore';
|
|
16
|
+
|
|
15
17
|
import { ROOT_LOAD_KEY, listDirectory, toServerPath } from './contents';
|
|
18
|
+
import { buildIgnoredEntries, loadGitignoreMatcher } from './gitignore';
|
|
16
19
|
import { FILE_BROWSER_ICONS } from './icons';
|
|
17
20
|
import type { XtralabFileBrowser } from './widget';
|
|
18
21
|
|
|
@@ -76,13 +79,57 @@ export function FileBrowserComponent(
|
|
|
76
79
|
paths: [],
|
|
77
80
|
initialExpansion: 'closed',
|
|
78
81
|
search: true,
|
|
79
|
-
icons: FILE_BROWSER_ICONS
|
|
82
|
+
icons: FILE_BROWSER_ICONS,
|
|
83
|
+
itemHeight: 24
|
|
80
84
|
});
|
|
81
85
|
|
|
82
86
|
React.useEffect(() => {
|
|
83
87
|
const knownDirs = new Map<string, LoadState>();
|
|
88
|
+
// Mirror of the canonical paths currently loaded into the tree. Kept in
|
|
89
|
+
// sync with `model.resetPaths`/`model.batch`/`model.add` so we can
|
|
90
|
+
// re-test every loaded path against the gitignore matcher whenever
|
|
91
|
+
// either side changes — the model itself does not expose a
|
|
92
|
+
// path-iteration API.
|
|
93
|
+
const loadedPaths = new Set<string>();
|
|
94
|
+
let gitignoreMatcher: Ignore | null = null;
|
|
84
95
|
let cancelled = false;
|
|
85
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Recompute the `ignored` git status entries from the current
|
|
99
|
+
* gitignore matcher and the set of loaded paths, and push them into
|
|
100
|
+
* the tree. Safe to call at any time: when the matcher is `null`
|
|
101
|
+
* (no `.gitignore` or it failed to load) we send an empty list, which
|
|
102
|
+
* also clears any statuses that may have been applied previously.
|
|
103
|
+
*/
|
|
104
|
+
const syncGitStatus = (): void => {
|
|
105
|
+
if (gitignoreMatcher === null) {
|
|
106
|
+
model.setGitStatus([]);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const entries = buildIgnoredEntries(gitignoreMatcher, loadedPaths);
|
|
110
|
+
model.setGitStatus(entries);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reload the workspace `.gitignore`, then re-apply the resulting
|
|
115
|
+
* ignored statuses. Called on initial mount and whenever the user
|
|
116
|
+
* triggers a refresh — the file may have been edited or created in
|
|
117
|
+
* between.
|
|
118
|
+
*/
|
|
119
|
+
const refreshGitignoreMatcher = async (): Promise<void> => {
|
|
120
|
+
let next: Ignore | null = null;
|
|
121
|
+
try {
|
|
122
|
+
next = await loadGitignoreMatcher(contentsManager);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error('xtralab: failed to load .gitignore', err);
|
|
125
|
+
}
|
|
126
|
+
if (cancelled) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
gitignoreMatcher = next;
|
|
130
|
+
syncGitStatus();
|
|
131
|
+
};
|
|
132
|
+
|
|
86
133
|
const fetchDirectory = async (canonicalPath: string): Promise<void> => {
|
|
87
134
|
const current = knownDirs.get(canonicalPath);
|
|
88
135
|
if (current === 'loading' || current === 'loaded') {
|
|
@@ -100,6 +147,10 @@ export function FileBrowserComponent(
|
|
|
100
147
|
}
|
|
101
148
|
if (canonicalPath === ROOT_LOAD_KEY) {
|
|
102
149
|
model.resetPaths(paths);
|
|
150
|
+
loadedPaths.clear();
|
|
151
|
+
for (const path of paths) {
|
|
152
|
+
loadedPaths.add(path);
|
|
153
|
+
}
|
|
103
154
|
} else {
|
|
104
155
|
// Filter out paths already in the model. The path-store throws
|
|
105
156
|
// when an explicit directory is added a second time, so any
|
|
@@ -111,6 +162,14 @@ export function FileBrowserComponent(
|
|
|
111
162
|
if (operations.length > 0) {
|
|
112
163
|
model.batch(operations);
|
|
113
164
|
}
|
|
165
|
+
// Mirror every directory child into `loadedPaths` regardless of
|
|
166
|
+
// whether it was just added or skipped above — paths skipped by
|
|
167
|
+
// the filter are already in the model from a prior add and so
|
|
168
|
+
// belong in the mirror too. `Set.add` is idempotent, so the
|
|
169
|
+
// duplicate adds are a no-op.
|
|
170
|
+
for (const path of paths) {
|
|
171
|
+
loadedPaths.add(path);
|
|
172
|
+
}
|
|
114
173
|
}
|
|
115
174
|
for (const subdir of subdirectories) {
|
|
116
175
|
if (!knownDirs.has(subdir)) {
|
|
@@ -118,6 +177,7 @@ export function FileBrowserComponent(
|
|
|
118
177
|
}
|
|
119
178
|
}
|
|
120
179
|
knownDirs.set(canonicalPath, 'loaded');
|
|
180
|
+
syncGitStatus();
|
|
121
181
|
} catch (err) {
|
|
122
182
|
console.error(
|
|
123
183
|
`xtralab: failed to load directory "${canonicalPath}"`,
|
|
@@ -216,6 +276,10 @@ export function FileBrowserComponent(
|
|
|
216
276
|
// Reset the tree contents and restore the load-state map so future
|
|
217
277
|
// expansions know which directories still need fetching.
|
|
218
278
|
model.resetPaths(allPaths);
|
|
279
|
+
loadedPaths.clear();
|
|
280
|
+
for (const path of allPaths) {
|
|
281
|
+
loadedPaths.add(path);
|
|
282
|
+
}
|
|
219
283
|
knownDirs.set(ROOT_LOAD_KEY, 'loaded');
|
|
220
284
|
subdirsByParent.forEach(subdirs => {
|
|
221
285
|
for (const subdir of subdirs) {
|
|
@@ -226,6 +290,13 @@ export function FileBrowserComponent(
|
|
|
226
290
|
}
|
|
227
291
|
});
|
|
228
292
|
|
|
293
|
+
// Refresh the `.gitignore` matcher in case the file was edited
|
|
294
|
+
// since the last load, then re-apply the resulting statuses to the
|
|
295
|
+
// newly-loaded paths. The reload runs in parallel with the rest of
|
|
296
|
+
// the refresh — `refreshGitignoreMatcher` calls `syncGitStatus`
|
|
297
|
+
// itself when it completes.
|
|
298
|
+
void refreshGitignoreMatcher();
|
|
299
|
+
|
|
229
300
|
// Re-expand the directories that were expanded before the refresh.
|
|
230
301
|
// We have to do this after `resetPaths` because the reset starts
|
|
231
302
|
// every directory in its initial collapsed state.
|
|
@@ -251,12 +322,14 @@ export function FileBrowserComponent(
|
|
|
251
322
|
return;
|
|
252
323
|
}
|
|
253
324
|
}
|
|
325
|
+
loadedPaths.add(canonicalPath);
|
|
254
326
|
if (canonicalPath.endsWith('/') && !knownDirs.has(canonicalPath)) {
|
|
255
327
|
// The new directory has no children yet, so mark it as already
|
|
256
328
|
// loaded — there's nothing to fetch and we don't want a stale
|
|
257
329
|
// "unloaded" entry to trigger a fetch on the next expand.
|
|
258
330
|
knownDirs.set(canonicalPath, 'loaded');
|
|
259
331
|
}
|
|
332
|
+
syncGitStatus();
|
|
260
333
|
const parent = parentOf(canonicalPath);
|
|
261
334
|
if (parent === ROOT_LOAD_KEY) {
|
|
262
335
|
return;
|
|
@@ -273,6 +346,7 @@ export function FileBrowserComponent(
|
|
|
273
346
|
|
|
274
347
|
knownDirs.set(ROOT_LOAD_KEY, 'unloaded');
|
|
275
348
|
void fetchDirectory(ROOT_LOAD_KEY);
|
|
349
|
+
void refreshGitignoreMatcher();
|
|
276
350
|
|
|
277
351
|
const unsubscribe = model.subscribe(() => {
|
|
278
352
|
knownDirs.forEach((state, canonicalPath) => {
|