@harborclient/sdk 0.6.12 → 0.6.15

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.
@@ -0,0 +1,855 @@
1
+ import { bridgeInvoke, bridgeOn } from './hcBridge.js';
2
+ import {
3
+ getContributionComponent,
4
+ getContributionHeaderActions,
5
+ getContributionIndicator,
6
+ registerContributionComponent,
7
+ registerContributionHeaderActions,
8
+ registerContributionIndicator
9
+ } from './contributionRegistry.js';
10
+ import { setHostReact } from './reactHost.js';
11
+
12
+ /** @type {Map<string, Set<(...args: unknown[]) => void | Promise<void>>>} */
13
+ const commandHandlers = new Map();
14
+
15
+ /** Plugin id prefix for built-in HarborClient host commands executed in the renderer. */
16
+ const HOST_COMMAND_OWNER = 'harborclient';
17
+
18
+ /**
19
+ * Parses a view-host role string into agent vs view contribution id.
20
+ *
21
+ * @param {string | null | undefined} role - Role query parameter from the shell URL.
22
+ * @returns {{ mode: 'agent' | 'view'; contributionId?: string }}
23
+ */
24
+ export function parseViewHostRole(role) {
25
+ if (role == null || role === 'agent') {
26
+ return { mode: 'agent' };
27
+ }
28
+ if (role.startsWith('view:')) {
29
+ return { mode: 'view', contributionId: role.slice('view:'.length) };
30
+ }
31
+ if (role === 'view') {
32
+ return { mode: 'view' };
33
+ }
34
+ return { mode: 'agent' };
35
+ }
36
+
37
+ /**
38
+ * Builds a disposable handle that removes one command handler registration.
39
+ *
40
+ * @param {string} scopedId - Namespaced command id.
41
+ * @param {(...args: unknown[]) => void | Promise<void>} handler - Handler to remove.
42
+ * @returns {{ dispose: () => void }}
43
+ */
44
+ function createCommandDisposable(scopedId, handler) {
45
+ return {
46
+ dispose: () => {
47
+ const handlers = commandHandlers.get(scopedId);
48
+ if (!handlers) {
49
+ return;
50
+ }
51
+ handlers.delete(handler);
52
+ if (handlers.size === 0) {
53
+ commandHandlers.delete(scopedId);
54
+ }
55
+ }
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Executes a registered plugin command inside this webview realm.
61
+ *
62
+ * @param {string} pluginId - Plugin manifest id.
63
+ * @param {string} commandId - Command id declared in the manifest.
64
+ * @param {unknown[]} args - Arguments passed to the handler.
65
+ */
66
+ export async function executeLocalPluginCommand(pluginId, commandId, ...args) {
67
+ const scopedId = `${pluginId}:${commandId}`;
68
+ const handlers = commandHandlers.get(scopedId);
69
+ if (!handlers) {
70
+ throw new Error(`Unknown plugin command: ${scopedId}`);
71
+ }
72
+ for (const handler of handlers) {
73
+ await handler(...args);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Creates the plugin activation context backed by the main-process broker.
79
+ *
80
+ * @param {object} options - Activation options parsed from the shell URL.
81
+ * @param {string} options.pluginId - Plugin manifest id.
82
+ * @param {'agent' | 'view'} options.mode - Agent runs logic; view renders one contribution.
83
+ * @param {string | undefined} options.contributionId - Manifest contribution id for view mode.
84
+ * @param {typeof import('react')} options.react - React namespace for this webview realm.
85
+ * @param {Record<string, unknown>} options.manifest - Parsed plugin manifest.
86
+ * @returns {import('../types').PluginContext}
87
+ */
88
+ export function createBridgedPluginContext({ pluginId, mode, contributionId, react, manifest }) {
89
+ const subscriptions = [];
90
+ const permissions = new Set(manifest.permissions ?? []);
91
+ const isAgent = mode === 'agent';
92
+
93
+ /**
94
+ * Asserts that the plugin declared a permission in its manifest.
95
+ *
96
+ * @param {string} permission - Required permission flag.
97
+ */
98
+ const assertPermission = (permission) => {
99
+ if (!permissions.has(permission)) {
100
+ throw new Error(`Plugin ${pluginId} lacks permission: ${permission}`);
101
+ }
102
+ };
103
+
104
+ /**
105
+ * Asserts UI permission for contribution registration.
106
+ */
107
+ const assertUi = () => assertPermission('ui');
108
+
109
+ /**
110
+ * Returns whether UI registration should run in this webview role.
111
+ */
112
+ const canRegisterUi = () => isAgent || mode === 'view';
113
+
114
+ /**
115
+ * Asserts that a contribution id is declared in manifest.contributes.
116
+ *
117
+ * @param {string} key - contributes.* key.
118
+ * @param {string} id - Contribution id.
119
+ */
120
+ const assertManifestContribution = (key, id) => {
121
+ const entries = manifest.contributes?.[key];
122
+ if (!Array.isArray(entries) || !entries.some((entry) => entry.id === id)) {
123
+ throw new Error(`Contribution id "${id}" is not declared in manifest.contributes.${key}.`);
124
+ }
125
+ };
126
+
127
+ /**
128
+ * Asserts that a menu command is declared in manifest.contributes.menus.
129
+ *
130
+ * @param {string} command - Command id referenced by the menu item.
131
+ */
132
+ const assertManifestMenuCommand = (command) => {
133
+ const entries = manifest.contributes?.menus;
134
+ if (!Array.isArray(entries) || !entries.some((entry) => entry.command === command)) {
135
+ throw new Error(`Command "${command}" is not declared in manifest.contributes.menus.`);
136
+ }
137
+ };
138
+
139
+ /**
140
+ * Registers a UI contribution locally and forwards metadata to the host when agent.
141
+ *
142
+ * @param {string} kind - Contribution bucket key.
143
+ * @param {string} id - Manifest contribution id.
144
+ * @param {Record<string, unknown>} metadata - Serializable metadata for the host registry.
145
+ * @param {unknown} component - React component registered in this realm.
146
+ * @param {object} [options] - Optional indicator/headerActions components.
147
+ * @param {unknown} [options.indicator] - Footer panel indicator component.
148
+ * @param {unknown} [options.headerActions] - Sidebar section header actions component.
149
+ * @returns {{ dispose: () => void }}
150
+ */
151
+ const registerUiContribution = (kind, id, metadata, component, options = {}) => {
152
+ assertUi();
153
+ registerContributionComponent(kind, id, component);
154
+ if (options.indicator) {
155
+ registerContributionIndicator(id, options.indicator);
156
+ }
157
+ if (options.headerActions) {
158
+ registerContributionHeaderActions(id, options.headerActions);
159
+ }
160
+
161
+ if (isAgent) {
162
+ void bridgeInvoke('registerContribution', {
163
+ kind,
164
+ contribution: { pluginId, ...metadata }
165
+ });
166
+ }
167
+
168
+ return {
169
+ dispose: () => {
170
+ if (isAgent) {
171
+ void bridgeInvoke('unregisterContribution', { kind, contributionId: id });
172
+ }
173
+ }
174
+ };
175
+ };
176
+
177
+ /**
178
+ * No-op UI registration in view webviews (agent owns metadata).
179
+ *
180
+ * @returns {{ dispose: () => void }}
181
+ */
182
+ const noopDisposable = () => ({ dispose: () => {} });
183
+
184
+ setHostReact(react);
185
+
186
+ return {
187
+ pluginId,
188
+ react,
189
+ subscriptions,
190
+ storage: {
191
+ get: async (key) => {
192
+ assertPermission('storage');
193
+ return bridgeInvoke('storage.get', { key });
194
+ },
195
+ set: async (key, value) => {
196
+ assertPermission('storage');
197
+ await bridgeInvoke('storage.set', { key, value });
198
+ }
199
+ },
200
+ database: {
201
+ query: (mode, sql, params, txnId) => {
202
+ assertPermission('database');
203
+ return bridgeInvoke('database.query', { mode, sql, params, txnId });
204
+ },
205
+ exec: (sql) => {
206
+ assertPermission('database');
207
+ return bridgeInvoke('database.exec', { sql });
208
+ },
209
+ beginTransaction: () => {
210
+ assertPermission('database');
211
+ return bridgeInvoke('database.beginTransaction');
212
+ },
213
+ endTransaction: (txnId, action) => {
214
+ assertPermission('database');
215
+ return bridgeInvoke('database.endTransaction', { txnId, action });
216
+ }
217
+ },
218
+ fs: {
219
+ pickFile: async (options) => {
220
+ assertPermission('filesystem:pick');
221
+ return bridgeInvoke('fs.pickFile', { options });
222
+ },
223
+ pickDirectory: async (defaultPath) => {
224
+ assertPermission('filesystem:pick');
225
+ return bridgeInvoke('fs.pickDirectory', { defaultPath: defaultPath ?? '' });
226
+ },
227
+ saveFile: async (content, options) => {
228
+ assertPermission('filesystem:pick');
229
+ return bridgeInvoke('fs.saveFile', { content, options });
230
+ },
231
+ readFile: async (path) => {
232
+ assertPermission('filesystem:read');
233
+ return bridgeInvoke('fs.readFile', { path });
234
+ },
235
+ writeFile: async (path, content) => {
236
+ assertPermission('filesystem:write');
237
+ await bridgeInvoke('fs.writeFile', { path, content });
238
+ },
239
+ watchFile: (path, listener) => {
240
+ assertPermission('filesystem:read');
241
+ const unsubscribe = bridgeOn(`fs.watch:${path}`, () => {
242
+ listener(path);
243
+ });
244
+ void bridgeInvoke('fs.watchFile', { path });
245
+ return { dispose: unsubscribe };
246
+ }
247
+ },
248
+ commands: {
249
+ register: (id, handler) => {
250
+ assertUi();
251
+ assertManifestContribution('commands', id);
252
+ if (!isAgent) {
253
+ return noopDisposable();
254
+ }
255
+ const scopedId = `${pluginId}:${id}`;
256
+ const handlers = commandHandlers.get(scopedId) ?? new Set();
257
+ handlers.add(handler);
258
+ commandHandlers.set(scopedId, handlers);
259
+ return createCommandDisposable(scopedId, handler);
260
+ },
261
+ execute: async (id, ...args) => {
262
+ const [ownerId, commandId] = id.includes(':') ? id.split(':', 2) : [pluginId, id];
263
+ if (ownerId === pluginId) {
264
+ await executeLocalPluginCommand(ownerId, commandId, ...args);
265
+ return;
266
+ }
267
+ if (ownerId === HOST_COMMAND_OWNER) {
268
+ await bridgeInvoke('commands.execute', { pluginId: ownerId, commandId, args });
269
+ return;
270
+ }
271
+ await bridgeInvoke('commands.executeRemote', { pluginId: ownerId, commandId, args });
272
+ }
273
+ },
274
+ themes: {
275
+ register: (theme) => {
276
+ assertUi();
277
+ assertManifestContribution('themes', theme.id);
278
+ if (!isAgent) {
279
+ return noopDisposable();
280
+ }
281
+ void bridgeInvoke('themes.register', { theme });
282
+ return {
283
+ dispose: () => {
284
+ void bridgeInvoke('themes.unregister', { themeId: theme.id });
285
+ }
286
+ };
287
+ },
288
+ getActive: async () => bridgeInvoke('themes.getActive'),
289
+ onDidChange: (listener) => {
290
+ const unsubscribe = bridgeOn('themes.changed', listener);
291
+ void bridgeInvoke('themes.getActive').then(listener);
292
+ return { dispose: unsubscribe };
293
+ }
294
+ },
295
+ ui: {
296
+ registerSettingsSection: (section) => {
297
+ assertManifestContribution('settingsSections', section.id);
298
+ if (!canRegisterUi()) {
299
+ return noopDisposable();
300
+ }
301
+ return registerUiContribution(
302
+ 'settingsSections',
303
+ section.id,
304
+ {
305
+ id: `plugin:${pluginId}:${section.id}`,
306
+ title: section.title,
307
+ contributionId: section.id
308
+ },
309
+ section.Component
310
+ );
311
+ },
312
+ registerSidebarPanel: (panel) => {
313
+ assertManifestContribution('sidebarPanels', panel.id);
314
+ if (!canRegisterUi()) {
315
+ return noopDisposable();
316
+ }
317
+ return registerUiContribution(
318
+ 'sidebarPanels',
319
+ panel.id,
320
+ {
321
+ id: `plugin:${pluginId}:${panel.id}`,
322
+ title: panel.title,
323
+ icon: panel.icon,
324
+ order: panel.order,
325
+ contributionId: panel.id
326
+ },
327
+ panel.Component
328
+ );
329
+ },
330
+ registerSidebarSection: (section) => {
331
+ assertManifestContribution('sidebarSections', section.id);
332
+ if (!canRegisterUi()) {
333
+ return noopDisposable();
334
+ }
335
+ return registerUiContribution(
336
+ 'sidebarSections',
337
+ section.id,
338
+ {
339
+ id: `plugin:${pluginId}:${section.id}`,
340
+ title: section.title,
341
+ order: section.order,
342
+ contributionId: section.id,
343
+ hasHeaderActions: Boolean(section.headerActions)
344
+ },
345
+ section.Component,
346
+ { headerActions: section.headerActions }
347
+ );
348
+ },
349
+ registerMainView: (view) => {
350
+ assertManifestContribution('mainViews', view.id);
351
+ if (!canRegisterUi()) {
352
+ return noopDisposable();
353
+ }
354
+ return registerUiContribution(
355
+ 'mainViews',
356
+ view.id,
357
+ { id: `plugin:${pluginId}:${view.id}`, title: view.title, contributionId: view.id },
358
+ view.Component
359
+ );
360
+ },
361
+ registerModal: (modal) => {
362
+ assertManifestContribution('modals', modal.id);
363
+ if (!canRegisterUi()) {
364
+ return noopDisposable();
365
+ }
366
+ return registerUiContribution(
367
+ 'modals',
368
+ modal.id,
369
+ { id: `plugin:${pluginId}:${modal.id}`, title: modal.title, contributionId: modal.id },
370
+ modal.Component
371
+ );
372
+ },
373
+ registerRequestTab: (tab) => {
374
+ assertManifestContribution('requestTabs', tab.id);
375
+ if (!canRegisterUi()) {
376
+ return noopDisposable();
377
+ }
378
+ return registerUiContribution(
379
+ 'requestTabs',
380
+ tab.id,
381
+ {
382
+ id: `plugin:${pluginId}:${tab.id}`,
383
+ title: tab.title,
384
+ order: tab.order,
385
+ contributionId: tab.id
386
+ },
387
+ tab.Component
388
+ );
389
+ },
390
+ registerResponseTab: (tab) => {
391
+ assertManifestContribution('responseTabs', tab.id);
392
+ if (!canRegisterUi()) {
393
+ return noopDisposable();
394
+ }
395
+ return registerUiContribution(
396
+ 'responseTabs',
397
+ tab.id,
398
+ {
399
+ id: `plugin:${pluginId}:${tab.id}`,
400
+ title: tab.title,
401
+ order: tab.order,
402
+ when: tab.when,
403
+ contributionId: tab.id
404
+ },
405
+ tab.Component
406
+ );
407
+ },
408
+ registerCollectionSettingsTab: (tab) => {
409
+ assertManifestContribution('collectionSettingsTabs', tab.id);
410
+ if (!canRegisterUi()) {
411
+ return noopDisposable();
412
+ }
413
+ return registerUiContribution(
414
+ 'collectionSettingsTabs',
415
+ tab.id,
416
+ {
417
+ id: `plugin:${pluginId}:${tab.id}`,
418
+ title: tab.title,
419
+ order: tab.order,
420
+ contributionId: tab.id
421
+ },
422
+ tab.Component
423
+ );
424
+ },
425
+ registerFooterPanel: (panel) => {
426
+ assertManifestContribution('footerPanels', panel.id);
427
+ if (!canRegisterUi()) {
428
+ return noopDisposable();
429
+ }
430
+ return registerUiContribution(
431
+ 'footerPanels',
432
+ panel.id,
433
+ {
434
+ id: `plugin:${pluginId}:${panel.id}`,
435
+ title: panel.title,
436
+ contributionId: panel.id,
437
+ hasIndicator: Boolean(panel.Indicator)
438
+ },
439
+ panel.Component,
440
+ { indicator: panel.Indicator }
441
+ );
442
+ },
443
+ registerMenuItem: (item) => {
444
+ assertManifestMenuCommand(item.command);
445
+ if (!isAgent) {
446
+ return noopDisposable();
447
+ }
448
+ void bridgeInvoke('registerContribution', {
449
+ kind: 'menuItems',
450
+ contribution: {
451
+ pluginId,
452
+ menu: item.menu,
453
+ command: item.command,
454
+ label: item.label,
455
+ group: item.group,
456
+ order: item.order
457
+ }
458
+ });
459
+ return {
460
+ dispose: () => {
461
+ void bridgeInvoke('unregisterContribution', {
462
+ kind: 'menuItems',
463
+ contributionId: `${item.menu}:${item.command}`
464
+ });
465
+ }
466
+ };
467
+ },
468
+ registerRequestToolbarAction: (action) => {
469
+ assertManifestContribution('requestToolbarActions', action.id);
470
+ if (!isAgent) {
471
+ return noopDisposable();
472
+ }
473
+ void bridgeInvoke('registerContribution', {
474
+ kind: 'requestToolbarActions',
475
+ contribution: {
476
+ pluginId,
477
+ id: action.id,
478
+ title: action.title,
479
+ command: action.command,
480
+ icon: action.icon,
481
+ order: action.order
482
+ }
483
+ });
484
+ return {
485
+ dispose: () => {
486
+ void bridgeInvoke('unregisterContribution', {
487
+ kind: 'requestToolbarActions',
488
+ contributionId: action.id
489
+ });
490
+ }
491
+ };
492
+ },
493
+ registerContextMenuItem: (item) => {
494
+ assertManifestContribution('contextMenus', item.id);
495
+ if (!isAgent) {
496
+ return noopDisposable();
497
+ }
498
+ void bridgeInvoke('registerContribution', {
499
+ kind: 'contextMenuItems',
500
+ contribution: {
501
+ pluginId,
502
+ id: item.id,
503
+ title: item.title,
504
+ command: item.command,
505
+ when: item.when,
506
+ group: item.group,
507
+ order: item.order
508
+ }
509
+ });
510
+ return {
511
+ dispose: () => {
512
+ void bridgeInvoke('unregisterContribution', {
513
+ kind: 'contextMenuItems',
514
+ contributionId: item.id
515
+ });
516
+ }
517
+ };
518
+ },
519
+ registerStatusBarItem: (item) => {
520
+ assertManifestContribution('statusBarItems', item.id);
521
+ if (!canRegisterUi()) {
522
+ return noopDisposable();
523
+ }
524
+ return registerUiContribution(
525
+ 'statusBarItems',
526
+ item.id,
527
+ {
528
+ id: `plugin:${pluginId}:${item.id}`,
529
+ alignment: item.alignment,
530
+ order: item.order,
531
+ contributionId: item.id
532
+ },
533
+ item.Component
534
+ );
535
+ },
536
+ showToast: (message, options) => {
537
+ assertUi();
538
+ void bridgeInvoke('ui.showToast', { message, options });
539
+ },
540
+ openModal: (modalId, context) => {
541
+ assertUi();
542
+ void bridgeInvoke('ui.openModal', { modalId, context });
543
+ },
544
+ closeModal: (modalId) => {
545
+ assertUi();
546
+ void bridgeInvoke('ui.closeModal', { modalId });
547
+ }
548
+ },
549
+ http: {
550
+ onAfterSend: (handler) => {
551
+ assertPermission('http');
552
+ const unsubscribe = bridgeOn('http.afterSend', (payload) => {
553
+ const { request, response } = payload ?? {};
554
+ return handler(request, response);
555
+ });
556
+ return { dispose: unsubscribe };
557
+ }
558
+ },
559
+ ipc: {
560
+ invoke: async (channel, ...args) => {
561
+ assertPermission('ipc');
562
+ return bridgeInvoke('ipc.invoke', { channel, args });
563
+ }
564
+ },
565
+ host: {
566
+ openRequestDraft: async (payload) => {
567
+ assertUi();
568
+ await bridgeInvoke('host.openRequestDraft', { payload });
569
+ },
570
+ loadRequest: async (requestId) => {
571
+ assertUi();
572
+ await bridgeInvoke('host.loadRequest', { requestId });
573
+ },
574
+ sendRequest: async () => {
575
+ assertUi();
576
+ await bridgeInvoke('host.sendRequest');
577
+ },
578
+ createEnvironmentWithVariables: async (name, variables) => {
579
+ assertUi();
580
+ return bridgeInvoke('host.createEnvironmentWithVariables', { name, variables });
581
+ },
582
+ updateEnvironmentVariables: async (environmentId, variables) => {
583
+ assertUi();
584
+ await bridgeInvoke('host.updateEnvironmentVariables', { environmentId, variables });
585
+ },
586
+ createCollection: async (payload) => {
587
+ assertUi();
588
+ return bridgeInvoke('host.createCollection', { payload });
589
+ },
590
+ listCollectionRequests: async (collectionId, folderId) => {
591
+ assertUi();
592
+ return bridgeInvoke('host.listCollectionRequests', { collectionId, folderId });
593
+ },
594
+ getCollectionMetadata: async (collectionId) => {
595
+ assertUi();
596
+ return bridgeInvoke('host.getCollectionMetadata', { collectionId });
597
+ },
598
+ logRequestToConsole: async (payload) => {
599
+ assertUi();
600
+ await bridgeInvoke('host.logRequestToConsole', { payload });
601
+ },
602
+ sendHttpRequest: async (input) => {
603
+ assertUi();
604
+ return bridgeInvoke('host.sendHttpRequest', { input });
605
+ },
606
+ clearResponse: async () => {
607
+ assertUi();
608
+ await bridgeInvoke('host.clearResponse');
609
+ }
610
+ }
611
+ };
612
+ }
613
+
614
+ /**
615
+ * Maps a manifest contributes key to the contribution registry bucket name.
616
+ *
617
+ * @param {string} contributionId - Manifest contribution id for view mode.
618
+ * @returns {string | undefined}
619
+ */
620
+ export function resolveContributionKindFromUrl(contributionId, searchParams) {
621
+ const kind = searchParams.get('kind');
622
+ return kind ?? undefined;
623
+ }
624
+
625
+ /**
626
+ * Mounts one contribution component into the view webview root element.
627
+ *
628
+ * @param {object} options - Mount options.
629
+ * @param {typeof import('react')} options.react - React namespace.
630
+ * @param {typeof import('react-dom/client')} options.reactDom - React DOM client namespace.
631
+ * @param {string} options.kind - Contribution bucket.
632
+ * @param {string} options.contributionId - Manifest contribution id.
633
+ * @param {HTMLElement} options.root - DOM mount target.
634
+ * @param {'content' | 'headerActions' | 'indicator'} [options.slot] - Contribution sub-slot.
635
+ * @returns {() => void} Cleanup function that unmounts the React root.
636
+ */
637
+ export function mountContributionView({
638
+ react,
639
+ reactDom,
640
+ kind,
641
+ contributionId,
642
+ root,
643
+ slot = 'content'
644
+ }) {
645
+ let Component;
646
+ if (slot === 'headerActions') {
647
+ Component = getContributionHeaderActions(contributionId);
648
+ } else if (slot === 'indicator') {
649
+ Component = getContributionIndicator(contributionId);
650
+ } else {
651
+ Component = getContributionComponent(kind, contributionId);
652
+ }
653
+ if (Component == null) {
654
+ throw new Error(`Unknown plugin contribution: ${kind}:${contributionId}`);
655
+ }
656
+
657
+ /** @type {unknown} */
658
+ let currentContext = null;
659
+
660
+ const needsContext =
661
+ kind === 'requestTabs' ||
662
+ kind === 'responseTabs' ||
663
+ kind === 'collectionSettingsTabs' ||
664
+ kind === 'modals';
665
+
666
+ const reactRoot = reactDom.createRoot(root);
667
+
668
+ /**
669
+ * Renders the contribution with the latest pushed context snapshot. For
670
+ * context-bearing contributions the first render is deferred until a context
671
+ * snapshot is available so the component never receives a null context.
672
+ */
673
+ const render = () => {
674
+ if (needsContext && currentContext == null) {
675
+ return;
676
+ }
677
+ const element = needsContext
678
+ ? react.createElement(Component, { context: currentContext })
679
+ : react.createElement(Component);
680
+ reactRoot.render(element);
681
+ };
682
+
683
+ /** @type {Set<string>} */
684
+ const FILL_SURFACE_KINDS = new Set([
685
+ 'footerPanels',
686
+ 'statusBarItems',
687
+ 'requestTabs',
688
+ 'responseTabs',
689
+ 'collectionSettingsTabs',
690
+ 'modals',
691
+ 'mainViews'
692
+ ]);
693
+
694
+ if (FILL_SURFACE_KINDS.has(kind)) {
695
+ document.body.classList.add('plugin-surface-fill');
696
+ }
697
+
698
+ if (slot === 'headerActions') {
699
+ document.body.classList.add('plugin-surface-header-actions');
700
+ document.documentElement.classList.add('plugin-surface-header-actions');
701
+ root.style.display = 'inline-flex';
702
+ root.style.width = 'fit-content';
703
+ root.style.maxWidth = '100%';
704
+ root.style.overflow = 'hidden';
705
+ }
706
+
707
+ /** @type {ResizeObserver | null} */
708
+ let resizeObserver = null;
709
+ /** @type {number | null} */
710
+ let resizeFrame = null;
711
+
712
+ if (slot === 'content' && !FILL_SURFACE_KINDS.has(kind)) {
713
+ /**
714
+ * Reports the full content height so the host webview can grow without an inner scrollbar.
715
+ */
716
+ const reportDocumentHeight = () => {
717
+ const height = Math.ceil(
718
+ Math.max(root.scrollHeight, root.getBoundingClientRect().height, root.offsetHeight)
719
+ );
720
+ if (height <= 0) {
721
+ return;
722
+ }
723
+ if (resizeFrame != null) {
724
+ cancelAnimationFrame(resizeFrame);
725
+ }
726
+ resizeFrame = requestAnimationFrame(() => {
727
+ resizeFrame = requestAnimationFrame(() => {
728
+ resizeFrame = null;
729
+ void bridgeInvoke('view.reportSize', { height, slot: 'content' });
730
+ });
731
+ });
732
+ };
733
+
734
+ resizeObserver = new ResizeObserver(() => {
735
+ reportDocumentHeight();
736
+ });
737
+ resizeObserver.observe(root);
738
+
739
+ /**
740
+ * Re-reports after React paints so context-deferred tabs measure their full form height.
741
+ */
742
+ const renderAndReport = () => {
743
+ render();
744
+ reportDocumentHeight();
745
+ };
746
+
747
+ const unsubscribe = bridgeOn('view.context', (payload) => {
748
+ currentContext = payload;
749
+ renderAndReport();
750
+ });
751
+
752
+ if (needsContext) {
753
+ // The host pushes context on mount/dom-ready, which can race ahead of this
754
+ // subscription, so pull the current snapshot now that we are listening.
755
+ void bridgeInvoke('view.getContext')
756
+ .then((context) => {
757
+ if (context != null && currentContext == null) {
758
+ currentContext = context;
759
+ renderAndReport();
760
+ }
761
+ })
762
+ .catch(() => {});
763
+ }
764
+
765
+ renderAndReport();
766
+
767
+ return () => {
768
+ unsubscribe();
769
+ resizeObserver?.disconnect();
770
+ if (resizeFrame != null) {
771
+ cancelAnimationFrame(resizeFrame);
772
+ }
773
+ };
774
+ }
775
+
776
+ if (slot === 'headerActions') {
777
+ /**
778
+ * Reports header action content size so the host webview matches the control.
779
+ */
780
+ const reportHeaderActionsSize = () => {
781
+ const measureTarget = root.firstElementChild ?? root;
782
+ const width = Math.ceil(
783
+ Math.max(
784
+ measureTarget.scrollWidth,
785
+ measureTarget.getBoundingClientRect().width,
786
+ measureTarget.offsetWidth
787
+ )
788
+ );
789
+ const height = Math.ceil(
790
+ Math.max(
791
+ measureTarget.scrollHeight,
792
+ measureTarget.getBoundingClientRect().height,
793
+ measureTarget.offsetHeight
794
+ )
795
+ );
796
+ if (width <= 0 && height <= 0) {
797
+ return;
798
+ }
799
+ if (resizeFrame != null) {
800
+ cancelAnimationFrame(resizeFrame);
801
+ }
802
+ resizeFrame = requestAnimationFrame(() => {
803
+ resizeFrame = requestAnimationFrame(() => {
804
+ resizeFrame = null;
805
+ void bridgeInvoke('view.reportSize', {
806
+ ...(width > 0 ? { width } : {}),
807
+ ...(height > 0 ? { height } : {}),
808
+ slot: 'headerActions'
809
+ });
810
+ });
811
+ });
812
+ };
813
+
814
+ resizeObserver = new ResizeObserver(() => {
815
+ reportHeaderActionsSize();
816
+ });
817
+ resizeObserver.observe(root);
818
+
819
+ render();
820
+ reportHeaderActionsSize();
821
+
822
+ return () => {
823
+ resizeObserver?.disconnect();
824
+ if (resizeFrame != null) {
825
+ cancelAnimationFrame(resizeFrame);
826
+ }
827
+ };
828
+ }
829
+
830
+ const unsubscribe = bridgeOn('view.context', (payload) => {
831
+ currentContext = payload;
832
+ render();
833
+ });
834
+
835
+ if (needsContext) {
836
+ // The host pushes context on mount/dom-ready, which can race ahead of this
837
+ // subscription, so pull the current snapshot now that we are listening.
838
+ void bridgeInvoke('view.getContext')
839
+ .then((context) => {
840
+ if (context != null && currentContext == null) {
841
+ currentContext = context;
842
+ render();
843
+ }
844
+ })
845
+ .catch(() => {});
846
+ }
847
+
848
+ render();
849
+
850
+ return () => {
851
+ unsubscribe();
852
+ };
853
+ }
854
+
855
+ export { getContributionComponent, getContributionHeaderActions, getContributionIndicator };