xtralab 0.1.2 → 0.3.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 (75) hide show
  1. package/README.md +129 -46
  2. package/lib/fileBrowser/commands.d.ts +2 -2
  3. package/lib/fileBrowser/commands.js +2 -2
  4. package/lib/fileBrowser/fileBrowser.js +72 -2
  5. package/lib/fileBrowser/gitignore.d.ts +29 -0
  6. package/lib/fileBrowser/gitignore.js +74 -0
  7. package/lib/fileBrowser/icons.d.ts +4 -4
  8. package/lib/fileBrowser/icons.js +4 -4
  9. package/lib/fileBrowser/widget.d.ts +2 -2
  10. package/lib/fileBrowser/widget.js +2 -2
  11. package/lib/git/api.d.ts +11 -0
  12. package/lib/git/api.js +19 -0
  13. package/lib/git/commands.d.ts +3 -1
  14. package/lib/git/commands.js +10 -4
  15. package/lib/git/diffWidget.d.ts +11 -0
  16. package/lib/git/diffWidget.js +90 -9
  17. package/lib/git/icons.d.ts +1 -1
  18. package/lib/git/icons.js +1 -1
  19. package/lib/git/index.js +22 -6
  20. package/lib/git/panelComponent.d.ts +3 -1
  21. package/lib/git/panelComponent.js +6 -3
  22. package/lib/index.js +9 -1
  23. package/lib/launcher/agents.d.ts +78 -0
  24. package/lib/launcher/agents.js +168 -0
  25. package/lib/launcher/availability.d.ts +12 -0
  26. package/lib/launcher/availability.js +53 -0
  27. package/lib/launcher/commands.d.ts +22 -0
  28. package/lib/launcher/commands.js +89 -0
  29. package/lib/launcher/dashboard.d.ts +46 -0
  30. package/lib/launcher/dashboard.js +214 -0
  31. package/lib/launcher/icons.d.ts +33 -0
  32. package/lib/launcher/icons.js +113 -0
  33. package/lib/launcher/index.d.ts +23 -0
  34. package/lib/launcher/index.js +191 -0
  35. package/lib/launcher/invocation.d.ts +29 -0
  36. package/lib/launcher/invocation.js +42 -0
  37. package/lib/menus/index.d.ts +14 -0
  38. package/lib/menus/index.js +102 -0
  39. package/lib/sidebar/index.d.ts +18 -0
  40. package/lib/sidebar/index.js +155 -0
  41. package/lib/statusBar/index.d.ts +14 -0
  42. package/lib/statusBar/index.js +89 -0
  43. package/lib/statusBar/widget.d.ts +121 -0
  44. package/lib/statusBar/widget.js +237 -0
  45. package/package.json +25 -3
  46. package/schema/launcher.json +70 -0
  47. package/schema/sidebar.json +57 -0
  48. package/src/fileBrowser/commands.ts +2 -2
  49. package/src/fileBrowser/fileBrowser.tsx +76 -2
  50. package/src/fileBrowser/gitignore.ts +86 -0
  51. package/src/fileBrowser/icons.ts +4 -4
  52. package/src/fileBrowser/widget.tsx +3 -3
  53. package/src/git/api.ts +20 -0
  54. package/src/git/commands.ts +19 -5
  55. package/src/git/diffWidget.tsx +109 -13
  56. package/src/git/icons.ts +1 -1
  57. package/src/git/index.ts +31 -5
  58. package/src/git/panelComponent.tsx +17 -5
  59. package/src/index.ts +9 -1
  60. package/src/launcher/agents.ts +248 -0
  61. package/src/launcher/availability.ts +73 -0
  62. package/src/launcher/commands.ts +103 -0
  63. package/src/launcher/dashboard.tsx +462 -0
  64. package/src/launcher/icons.ts +123 -0
  65. package/src/launcher/index.ts +228 -0
  66. package/src/launcher/invocation.ts +45 -0
  67. package/src/menus/index.ts +124 -0
  68. package/src/sidebar/index.ts +195 -0
  69. package/src/statusBar/index.ts +115 -0
  70. package/src/statusBar/widget.tsx +318 -0
  71. package/style/git.css +4 -8
  72. package/style/index.css +2 -0
  73. package/style/index.js +2 -0
  74. package/style/launcher.css +323 -0
  75. package/style/statusBar.css +95 -0
@@ -0,0 +1,228 @@
1
+ import {
2
+ ILabShell,
3
+ JupyterFrontEnd,
4
+ JupyterFrontEndPlugin
5
+ } from '@jupyterlab/application';
6
+ import { ICommandPalette, MainAreaWidget } from '@jupyterlab/apputils';
7
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
8
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
9
+ import { launcherIcon } from '@jupyterlab/ui-components';
10
+ import { find } from '@lumino/algorithm';
11
+ import type { ReadonlyPartialJSONObject } from '@lumino/coreutils';
12
+ import type { IDisposable } from '@lumino/disposable';
13
+ import type { Widget } from '@lumino/widgets';
14
+
15
+ import { mergeAgents, type IAgent, type IAgentSettings } from './agents';
16
+ import { fetchAvailableCommands } from './availability';
17
+ import { CREATE_LAUNCHER_COMMAND, registerAgentCommands } from './commands';
18
+ import { LauncherDashboard } from './dashboard';
19
+
20
+ const PLUGIN_ID = 'xtralab:launcher';
21
+
22
+ /**
23
+ * The xtralab launcher plugin. Replaces the stock JupyterLab launcher
24
+ * (which is disabled via `package.json`'s `jupyterlab.disabledExtensions`)
25
+ * with an agent-focused dashboard: an optional initial prompt, a row of
26
+ * agent buttons (Claude, Codex, Gemini, …), and a collapsible list of
27
+ * changed files (clickable into the diff viewer) below them. Clicking an
28
+ * agent opens a fresh terminal and pipes the agent's command into it; if
29
+ * the prompt textarea is non-empty, the prompt is shell-quoted and
30
+ * spliced into the launch line per the agent's `promptArgs` recipe.
31
+ *
32
+ * The agent list is the merge of xtralab's defaults with the user's
33
+ * `xtralab:launcher` settings, then filtered by a server-side `which`
34
+ * check so users only see agents that are actually installed. Agents with
35
+ * `requireAvailable: false` (e.g. shell aliases) skip the filter.
36
+ *
37
+ * The plugin deliberately does NOT provide the `ILauncher` token: other
38
+ * extensions register notebook/console/terminal cards on it as a side
39
+ * effect, which would defeat the point of the agent-only launcher. If an
40
+ * extension needs to surface itself, we'll add it here explicitly.
41
+ */
42
+ const plugin: JupyterFrontEndPlugin<void> = {
43
+ id: PLUGIN_ID,
44
+ description:
45
+ 'An agent-focused launcher that replaces the default JupyterLab launcher.',
46
+ autoStart: true,
47
+ optional: [ILabShell, ICommandPalette, ISettingRegistry, ITranslator],
48
+ activate: async (
49
+ app: JupyterFrontEnd,
50
+ labShell: ILabShell | null,
51
+ palette: ICommandPalette | null,
52
+ settingRegistry: ISettingRegistry | null,
53
+ translator: ITranslator | null
54
+ ): Promise<void> => {
55
+ const { commands, shell } = app;
56
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
57
+
58
+ // The currently active agent list. Updated whenever settings change so
59
+ // every freshly-created launcher widget renders the latest list.
60
+ let currentAgents: IAgent[] = [];
61
+
62
+ // Track command registrations so a settings change can wipe them
63
+ // before re-registering — without this the command palette would
64
+ // accumulate stale entries when the agent list shrinks.
65
+ let registered: IDisposable | null = null;
66
+
67
+ const applyAgents = async (overrides: IAgentSettings[]): Promise<void> => {
68
+ const agents = mergeAgents(overrides);
69
+ const filtered = await filterByAvailability(agents);
70
+
71
+ registered?.dispose();
72
+ registered = registerAgentCommands(app, filtered);
73
+ currentAgents = filtered;
74
+ };
75
+
76
+ const readOverrides = (
77
+ settings: ISettingRegistry.ISettings
78
+ ): IAgentSettings[] => {
79
+ const raw = settings.composite.agents;
80
+ return Array.isArray(raw) ? (raw as IAgentSettings[]) : [];
81
+ };
82
+
83
+ if (settingRegistry) {
84
+ try {
85
+ const settings = await settingRegistry.load(PLUGIN_ID);
86
+ await applyAgents(readOverrides(settings));
87
+ settings.changed.connect(async () => {
88
+ try {
89
+ await applyAgents(readOverrides(settings));
90
+ } catch (reason) {
91
+ console.error(
92
+ 'xtralab: failed to reapply launcher settings',
93
+ reason
94
+ );
95
+ }
96
+ });
97
+ } catch (reason) {
98
+ console.error('xtralab: failed to load launcher settings', reason);
99
+ // Settings load failed — fall back to defaults so the launcher
100
+ // still has cards instead of going silent.
101
+ await applyAgents([]);
102
+ }
103
+ } else {
104
+ await applyAgents([]);
105
+ }
106
+
107
+ commands.addCommand(CREATE_LAUNCHER_COMMAND, {
108
+ label: trans.__('New Launcher'),
109
+ execute: (args: ReadonlyPartialJSONObject) => {
110
+ const id = Private.nextId();
111
+ const onAgentLaunch = (item: Widget): void => {
112
+ // When an agent command returns a Widget that ends up in the main
113
+ // area, slot it where this launcher used to sit so opening an
114
+ // agent feels like the launcher transformed into the terminal.
115
+ // Disposing the inner ReactWidget cascades to the MainAreaWidget
116
+ // host via its `content.disposed` connection.
117
+ if (find(shell.widgets('main'), w => w === item)) {
118
+ shell.add(item, 'main', { ref: id });
119
+ launcher.dispose();
120
+ }
121
+ };
122
+ const launcher = new LauncherDashboard({
123
+ commands,
124
+ agents: currentAgents,
125
+ onAgentLaunch,
126
+ // Empty repoPath/cwd matches the JupyterLab convention used by
127
+ // the git panel and the stock launcher: let the server resolve
128
+ // the working tree from its root directory.
129
+ repoPath: '',
130
+ cwd: ''
131
+ });
132
+ launcher.title.icon = launcherIcon;
133
+ launcher.title.label = trans.__('Launcher');
134
+
135
+ const main = new MainAreaWidget({ content: launcher });
136
+ // Hide the close button when the launcher is the only thing in the
137
+ // main area: closing it would leave the user staring at an empty
138
+ // shell with no way back.
139
+ main.title.closable = !!Array.from(shell.widgets('main')).length;
140
+ main.id = id;
141
+
142
+ shell.add(main, 'main', {
143
+ activate: args['activate'] as boolean,
144
+ ref: args['ref'] as string
145
+ });
146
+
147
+ if (labShell) {
148
+ labShell.layoutModified.connect(() => {
149
+ main.title.closable =
150
+ Array.from(labShell.widgets('main')).length > 1;
151
+ }, main);
152
+ }
153
+
154
+ return main;
155
+ }
156
+ });
157
+
158
+ if (palette) {
159
+ palette.addItem({
160
+ command: CREATE_LAUNCHER_COMMAND,
161
+ category: trans.__('Launcher')
162
+ });
163
+ }
164
+
165
+ if (labShell) {
166
+ void app.restored.then(() => {
167
+ const maybeCreate = (): void => {
168
+ if (labShell.isEmpty('main')) {
169
+ void commands.execute(CREATE_LAUNCHER_COMMAND);
170
+ }
171
+ };
172
+ labShell.layoutModified.connect(() => {
173
+ maybeCreate();
174
+ });
175
+ // Layout has settled by the time `app.restored` resolves; if it's
176
+ // empty (fresh start, no restored widgets) the connect above won't
177
+ // fire on its own — kick it off here.
178
+ maybeCreate();
179
+ });
180
+
181
+ labShell.addButtonEnabled = true;
182
+ labShell.addRequested.connect((sender, arg) => {
183
+ const ref =
184
+ arg.currentTitle?.owner.id ||
185
+ arg.titles[arg.titles.length - 1].owner.id;
186
+ return commands.execute(CREATE_LAUNCHER_COMMAND, { ref });
187
+ });
188
+ }
189
+ }
190
+ };
191
+
192
+ /**
193
+ * Drop agents whose command isn't on `$PATH`, except for entries that
194
+ * explicitly opt out of the check via `requireAvailable: false` (typical
195
+ * use case: shell aliases the user wants surfaced regardless).
196
+ *
197
+ * If the availability endpoint can't be reached we fail open and return
198
+ * the input unchanged — better to show an unreachable agent than to hide
199
+ * the entire launcher because the server extension didn't load.
200
+ */
201
+ async function filterByAvailability(agents: IAgent[]): Promise<IAgent[]> {
202
+ const commands = agents
203
+ .filter(agent => agent.requireAvailable)
204
+ .map(agent => agent.command);
205
+ if (commands.length === 0) {
206
+ return agents;
207
+ }
208
+ const available = await fetchAvailableCommands(commands);
209
+ if (!available) {
210
+ return agents;
211
+ }
212
+ return agents.filter(
213
+ agent => !agent.requireAvailable || available.has(agent.command)
214
+ );
215
+ }
216
+
217
+ namespace Private {
218
+ let counter = 0;
219
+
220
+ /**
221
+ * Returns the next unique launcher widget id.
222
+ */
223
+ export function nextId(): string {
224
+ return `launcher-${counter++}`;
225
+ }
226
+ }
227
+
228
+ export default plugin;
@@ -0,0 +1,45 @@
1
+ import type { IAgent } from './agents';
2
+
3
+ /**
4
+ * Quote a string so it can be safely passed as a single argument on a
5
+ * bash/zsh command line that is *typed into an interactive terminal* —
6
+ * not the usual file-parsing context.
7
+ *
8
+ * The distinction matters: an actual newline byte (0x0A) sent into a PTY
9
+ * is interpreted by the line discipline as Enter regardless of whether
10
+ * it sits inside `'...'` quotes, so the shell never sees the closing
11
+ * quote. We therefore use ANSI-C `$'...'` quoting and emit `\n` (and
12
+ * `\r`) as their two-character escape sequences; the shell expands them
13
+ * to real newlines after the line is submitted, when readline is no
14
+ * longer involved. Backslashes and single quotes are escaped first so
15
+ * the source text round-trips unchanged.
16
+ */
17
+ export function shellQuote(value: string): string {
18
+ const escaped = value
19
+ .replace(/\\/g, '\\\\')
20
+ .replace(/'/g, "\\'")
21
+ .replace(/\n/g, '\\n')
22
+ .replace(/\r/g, '\\r');
23
+ return `$'${escaped}'`;
24
+ }
25
+
26
+ /**
27
+ * Compose the literal command line typed into a fresh terminal for `agent`.
28
+ * When `prompt` is empty (or whitespace-only), this is just `agent.command`.
29
+ * When non-empty, the prompt is shell-quoted and spliced in using the
30
+ * agent's `promptArgs` recipe — see `IAgent.promptArgs` for the encoding.
31
+ *
32
+ * Returns `agent.command` unchanged when the agent declares no prompt
33
+ * support (`promptArgs === undefined`), so a stray prompt typed into the
34
+ * launcher does not silently mutate the launch line for that agent. The
35
+ * launcher UI separately prevents the click in that case so the dropped
36
+ * prompt is never surprising to the user.
37
+ */
38
+ export function buildAgentInvocation(agent: IAgent, prompt: string): string {
39
+ const trimmed = prompt.trim();
40
+ if (trimmed.length === 0 || agent.promptArgs === undefined) {
41
+ return agent.command;
42
+ }
43
+ const tokens = [agent.command, ...agent.promptArgs, shellQuote(trimmed)];
44
+ return tokens.filter(token => token.length > 0).join(' ');
45
+ }
@@ -0,0 +1,124 @@
1
+ import {
2
+ ILabShell,
3
+ JupyterFrontEnd,
4
+ JupyterFrontEndPlugin
5
+ } from '@jupyterlab/application';
6
+ import { IMainMenu } from '@jupyterlab/mainmenu';
7
+ import type { IRankedMenu } from '@jupyterlab/ui-components';
8
+ import type { Menu, MenuBar, Widget } from '@lumino/widgets';
9
+
10
+ const PLUGIN_ID = 'xtralab:menus';
11
+
12
+ /**
13
+ * Hide the "Run" and "Kernel" top-level menus while no kernel-using widget
14
+ * is open in the main area. The agent-first launcher does not use kernels,
15
+ * so showing those menus on a fresh workspace surfaces commands the user
16
+ * cannot meaningfully invoke; remove them until a notebook or console is
17
+ * opened, then restore them at their original ranks.
18
+ *
19
+ * Detection is duck-typed on `sessionContext`: NotebookPanel and ConsolePanel
20
+ * both expose it, which avoids pulling in `@jupyterlab/notebook` and
21
+ * `@jupyterlab/console` just to identify their widgets.
22
+ */
23
+ const plugin: JupyterFrontEndPlugin<void> = {
24
+ id: PLUGIN_ID,
25
+ description:
26
+ 'Toggle the Run and Kernel main menus based on whether any kernel-using widget is open.',
27
+ autoStart: true,
28
+ requires: [IMainMenu, ILabShell],
29
+ activate: (
30
+ app: JupyterFrontEnd,
31
+ mainMenu: IMainMenu,
32
+ labShell: ILabShell
33
+ ): void => {
34
+ // The interface types (IRunMenu, IKernelMenu) extend IRankedMenu, but
35
+ // the concrete instances are RankedMenu/Menu — cast so we can hand them
36
+ // to MenuBar.removeMenu and IMainMenu.addMenu, which expect a `Menu`.
37
+ const menuBar = mainMenu as unknown as MenuBar;
38
+ const runMenu = mainMenu.runMenu as unknown as Menu;
39
+ const kernelMenu = mainMenu.kernelMenu as unknown as Menu;
40
+
41
+ // Capture the schema-assigned ranks so the menus reappear in their
42
+ // original slots between View (rank 3) and Tabs (rank 500). Falling
43
+ // back to the hard-coded defaults from `MainMenu` keeps things sane
44
+ // if the schema ever omits the rank.
45
+ const runRank = mainMenu.runMenu.rank ?? 4;
46
+ const kernelRank = mainMenu.kernelMenu.rank ?? 5;
47
+
48
+ const isPresent = (menu: Menu): boolean =>
49
+ Array.from(menuBar.menus).indexOf(menu) > -1;
50
+
51
+ const update = (): void => {
52
+ const show = hasKernelWidget(labShell);
53
+ if (show) {
54
+ // Don't go through `mainMenu.addMenu`: it picks the insertion index
55
+ // from a private `_items` array that `removeMenu` doesn't update,
56
+ // so re-adding lands the menu at the wrong position. Insert
57
+ // directly on the MenuBar by walking current menus and finding
58
+ // the first sibling with a higher rank.
59
+ if (!isPresent(runMenu)) {
60
+ insertByRank(menuBar, runMenu, runRank);
61
+ }
62
+ if (!isPresent(kernelMenu)) {
63
+ insertByRank(menuBar, kernelMenu, kernelRank);
64
+ }
65
+ } else {
66
+ if (isPresent(runMenu)) {
67
+ menuBar.removeMenu(runMenu);
68
+ }
69
+ if (isPresent(kernelMenu)) {
70
+ menuBar.removeMenu(kernelMenu);
71
+ }
72
+ }
73
+ };
74
+
75
+ void app.restored.then(() => {
76
+ update();
77
+ labShell.layoutModified.connect(update);
78
+ });
79
+ }
80
+ };
81
+
82
+ /**
83
+ * True iff any widget in the main area exposes a `sessionContext` — the
84
+ * shared marker for NotebookPanel, ConsolePanel, and kernel-attached file
85
+ * editors. We don't import their concrete types because that would drag
86
+ * `@jupyterlab/notebook` and `@jupyterlab/console` into xtralab's bundle
87
+ * just to do an `instanceof` check.
88
+ */
89
+ function hasKernelWidget(labShell: ILabShell): boolean {
90
+ for (const widget of labShell.widgets('main')) {
91
+ if (hasSessionContext(widget)) {
92
+ return true;
93
+ }
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function hasSessionContext(widget: Widget): boolean {
99
+ const candidate = widget as unknown as { sessionContext?: unknown };
100
+ return (
101
+ candidate.sessionContext !== null && candidate.sessionContext !== undefined
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Insert `menu` into `menuBar` at the position dictated by `rank`, using
107
+ * the existing menus' ranks to find the slot. Menus without a numeric
108
+ * rank are treated as +Infinity so they sink to the end (matching the
109
+ * convention in `MenuFactory.createMenus`).
110
+ */
111
+ function insertByRank(menuBar: MenuBar, menu: Menu, rank: number): void {
112
+ const menus = menuBar.menus;
113
+ for (let i = 0; i < menus.length; i++) {
114
+ const r = (menus[i] as unknown as IRankedMenu).rank;
115
+ const otherRank = typeof r === 'number' ? r : Number.POSITIVE_INFINITY;
116
+ if (otherRank > rank) {
117
+ menuBar.insertMenu(i, menu);
118
+ return;
119
+ }
120
+ }
121
+ menuBar.addMenu(menu);
122
+ }
123
+
124
+ export default plugin;
@@ -0,0 +1,195 @@
1
+ import {
2
+ ILabShell,
3
+ JupyterFrontEnd,
4
+ JupyterFrontEndPlugin
5
+ } from '@jupyterlab/application';
6
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
7
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
8
+ import type { Widget } from '@lumino/widgets';
9
+
10
+ const PLUGIN_ID = 'xtralab:sidebar';
11
+
12
+ /**
13
+ * Each entry pairs an upstream sidebar widget id with the rank it was
14
+ * originally added at. The ranks come from the upstream extensions —
15
+ * `filebrowser-extension` adds the default browser at rank 100 and
16
+ * `running-extension` adds the running panel at rank 200 — and we reuse
17
+ * them when re-adding so the tab lands back in its usual position.
18
+ */
19
+ interface ITarget {
20
+ id: string;
21
+ rank: number;
22
+ settingKey: 'showDefaultFileBrowser' | 'showRunningSessions';
23
+ command: string;
24
+ label: (trans: ReturnType<ITranslator['load']>) => string;
25
+ }
26
+
27
+ const TARGETS: ITarget[] = [
28
+ {
29
+ id: 'filebrowser',
30
+ rank: 100,
31
+ settingKey: 'showDefaultFileBrowser',
32
+ command: 'xtralab:toggle-default-filebrowser',
33
+ label: trans => trans.__('Default File Browser')
34
+ },
35
+ {
36
+ id: 'jp-running-sessions',
37
+ rank: 200,
38
+ settingKey: 'showRunningSessions',
39
+ command: 'xtralab:toggle-running-sessions',
40
+ label: trans => trans.__('Running Terminals and Kernels')
41
+ }
42
+ ];
43
+
44
+ /**
45
+ * Lets the user toggle individual left-sidebar tabs from
46
+ * View > Appearance > Left Sidebar. Removing a widget from a `LabShell`
47
+ * side area is done by setting `widget.parent = null`: the StackedPanel
48
+ * emits `widgetRemoved`, which the SideBarHandler responds to by removing
49
+ * the matching tab. The widget instance is preserved, so we can re-add it
50
+ * later via `labShell.add` with its original rank.
51
+ *
52
+ * The menu placement is contributed declaratively in
53
+ * `schema/sidebar.json`, so this plugin only registers commands and
54
+ * applies state — it does not touch `IMainMenu` directly.
55
+ *
56
+ * The preference is persisted to `xtralab:sidebar` settings so the choice
57
+ * survives reloads.
58
+ */
59
+ const plugin: JupyterFrontEndPlugin<void> = {
60
+ id: PLUGIN_ID,
61
+ description:
62
+ 'Toggle visibility of selected sidebar tabs (default file browser, running sessions) from the View menu.',
63
+ autoStart: true,
64
+ requires: [ILabShell],
65
+ optional: [ISettingRegistry, ITranslator],
66
+ activate: async (
67
+ app: JupyterFrontEnd,
68
+ labShell: ILabShell,
69
+ settingRegistry: ISettingRegistry | null,
70
+ translator: ITranslator | null
71
+ ): Promise<void> => {
72
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
73
+ const { commands } = app;
74
+
75
+ // Cache widget instances so we can re-add them after `widget.parent =
76
+ // null` has detached them from the sidebar. The widget is no longer
77
+ // reachable through `labShell.widgets('left')` once removed, so a
78
+ // missing cache entry would leave us unable to bring the tab back.
79
+ const widgetCache = new Map<string, Widget>();
80
+
81
+ const findInSidebar = (id: string): Widget | null => {
82
+ for (const w of labShell.widgets('left')) {
83
+ if (w.id === id) {
84
+ return w;
85
+ }
86
+ }
87
+ return null;
88
+ };
89
+
90
+ const captureWidget = (id: string): Widget | null => {
91
+ const cached = widgetCache.get(id);
92
+ if (cached && !cached.isDisposed) {
93
+ return cached;
94
+ }
95
+ const found = findInSidebar(id);
96
+ if (found) {
97
+ widgetCache.set(id, found);
98
+ }
99
+ return found;
100
+ };
101
+
102
+ let settings: ISettingRegistry.ISettings | null = null;
103
+
104
+ const readPreference = (target: ITarget): boolean => {
105
+ if (!settings) {
106
+ return true;
107
+ }
108
+ const value = settings.composite[target.settingKey];
109
+ return typeof value === 'boolean' ? value : true;
110
+ };
111
+
112
+ const apply = (target: ITarget): void => {
113
+ const widget = captureWidget(target.id);
114
+ if (!widget) {
115
+ // The upstream widget hasn't been added yet (or the extension is
116
+ // disabled). Nothing to do — the next time `apply` runs and finds
117
+ // it, the preference will take effect.
118
+ return;
119
+ }
120
+ const present = findInSidebar(target.id) !== null;
121
+ const wantPresent = readPreference(target);
122
+ if (wantPresent && !present) {
123
+ labShell.add(widget, 'left', { rank: target.rank });
124
+ } else if (!wantPresent && present) {
125
+ // Setting `parent = null` triggers SideBarHandler's
126
+ // `_onWidgetRemoved`, which strips the tab while leaving the
127
+ // widget instance alive for a later re-add.
128
+ widget.parent = null;
129
+ }
130
+ };
131
+
132
+ const applyAll = (): void => {
133
+ for (const target of TARGETS) {
134
+ apply(target);
135
+ }
136
+ };
137
+
138
+ for (const target of TARGETS) {
139
+ commands.addCommand(target.command, {
140
+ label: target.label(trans),
141
+ isToggled: () => readPreference(target),
142
+ execute: async () => {
143
+ const next = !readPreference(target);
144
+ if (settings) {
145
+ try {
146
+ await settings.set(target.settingKey, next);
147
+ // `settings.changed` will fire and drive `apply` + the
148
+ // command-state refresh. Returning here keeps the toggle's
149
+ // single source of truth in the settings registry.
150
+ return;
151
+ } catch (reason) {
152
+ console.error(
153
+ `xtralab: failed to persist ${target.settingKey}`,
154
+ reason
155
+ );
156
+ }
157
+ }
158
+ // No settings registry available (or the write failed): fall
159
+ // back to applying the change in-memory so the toggle still
160
+ // works for the current session.
161
+ apply(target);
162
+ commands.notifyCommandChanged(target.command);
163
+ }
164
+ });
165
+ }
166
+
167
+ if (settingRegistry) {
168
+ try {
169
+ settings = await settingRegistry.load(PLUGIN_ID);
170
+ settings.changed.connect(() => {
171
+ applyAll();
172
+ for (const target of TARGETS) {
173
+ commands.notifyCommandChanged(target.command);
174
+ }
175
+ });
176
+ } catch (reason) {
177
+ console.error('xtralab: failed to load sidebar settings', reason);
178
+ }
179
+ }
180
+
181
+ void app.restored.then(() => {
182
+ // Capture references first so a hidden-on-startup widget is still
183
+ // recoverable later. `apply` would also do the lookup, but only on
184
+ // the targets that need to stay visible — by capturing every target
185
+ // here we keep the cache populated for both directions of the
186
+ // toggle.
187
+ for (const target of TARGETS) {
188
+ captureWidget(target.id);
189
+ }
190
+ applyAll();
191
+ });
192
+ }
193
+ };
194
+
195
+ export default plugin;