@harborclient/sdk 0.6.10 → 0.6.14

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.
@@ -5,7 +5,7 @@ import { checkboxBox, checkboxInput } from './classes.js';
5
5
  */
6
6
  export function Checkbox({ ref, className, ...props }) {
7
7
  const wrapperClasses = className
8
- ? `relative inline-flex shrink-0 ${className}`
9
- : 'relative inline-flex shrink-0';
8
+ ? `relative inline-flex h-[18px] w-[18px] shrink-0 leading-none ${className}`
9
+ : 'relative inline-flex h-[18px] w-[18px] shrink-0 leading-none';
10
10
  return (_jsxs("span", { className: wrapperClasses, children: [_jsx("input", { ref: ref, type: "checkbox", className: checkboxInput, ...props }), _jsx("span", { className: checkboxBox, "aria-hidden": true, children: _jsx("svg", { className: "h-3 w-3", viewBox: "0 0 12 12", fill: "none", xmlns: "http://www.w3.org/2000/svg", children: _jsx("path", { d: "M2.5 6L5 8.5L9.5 3.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) }) })] }));
11
11
  }
@@ -5,7 +5,7 @@ import { radioCircle, radioInput } from './classes.js';
5
5
  */
6
6
  export function Radio({ ref, className, ...props }) {
7
7
  const wrapperClasses = className
8
- ? `relative inline-flex shrink-0 ${className}`
9
- : 'relative inline-flex shrink-0';
8
+ ? `relative inline-flex h-[18px] w-[18px] shrink-0 leading-none ${className}`
9
+ : 'relative inline-flex h-[18px] w-[18px] shrink-0 leading-none';
10
10
  return (_jsxs("span", { className: wrapperClasses, children: [_jsx("input", { ref: ref, type: "radio", className: radioInput, ...props }), _jsx("span", { className: radioCircle, "aria-hidden": true, children: _jsx("span", { className: "h-2 w-2 rounded-full bg-accent" }) })] }));
11
11
  }
@@ -16,11 +16,11 @@ export declare const surfaceField = "w-full rounded-md border border-separator b
16
16
  /** Transparent overlay checkbox input sized to {@link checkboxBox}. */
17
17
  export declare const checkboxInput = "peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed";
18
18
  /** Custom checkbox box styled via `peer-checked` / `peer-focus-visible` on {@link checkboxInput}. */
19
- export declare const checkboxBox = "flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border border-separator bg-field text-white peer-checked:border-accent peer-checked:bg-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&>svg]:opacity-0 peer-checked:[&>svg]:opacity-100";
19
+ export declare const checkboxBox = "pointer-events-none flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border border-separator bg-field text-white peer-checked:border-accent peer-checked:bg-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&>svg]:opacity-0 peer-checked:[&>svg]:opacity-100";
20
20
  /** Transparent overlay radio input sized to {@link radioCircle}. */
21
21
  export declare const radioInput = "peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed";
22
22
  /** Custom radio circle styled via `peer-checked` / `peer-focus-visible` on {@link radioInput}. */
23
- export declare const radioCircle = "flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full border border-separator bg-field peer-checked:border-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&>span]:opacity-0 peer-checked:[&>span]:opacity-100";
23
+ export declare const radioCircle = "pointer-events-none flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full border border-separator bg-field peer-checked:border-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&>span]:opacity-0 peer-checked:[&>span]:opacity-100";
24
24
  /**
25
25
  * Merges a field variant preset with optional caller classes.
26
26
  *
@@ -1 +1 @@
1
- {"version":3,"file":"classes.d.ts","sourceRoot":"","sources":["../../../src/components/forms/classes.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;AAE3D;;GAEG;AACH,eAAO,MAAM,UAAU,gEAAgE,CAAC;AAExF,mEAAmE;AACnE,eAAO,MAAM,KAAK,4FACyE,CAAC;AAE5F,wDAAwD;AACxD,eAAO,MAAM,YAAY,uFAC6D,CAAC;AAEvF,uEAAuE;AACvE,eAAO,MAAM,aAAa,iGACsE,CAAC;AAEjG,qGAAqG;AACrG,eAAO,MAAM,WAAW,iZACwX,CAAC;AAEjZ,oEAAoE;AACpE,eAAO,MAAM,UAAU,iGACyE,CAAC;AAEjG,kGAAkG;AAClG,eAAO,MAAM,WAAW,sXAC6V,CAAC;AAOtX;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAM/F"}
1
+ {"version":3,"file":"classes.d.ts","sourceRoot":"","sources":["../../../src/components/forms/classes.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;AAE3D;;GAEG;AACH,eAAO,MAAM,UAAU,gEAAgE,CAAC;AAExF,mEAAmE;AACnE,eAAO,MAAM,KAAK,4FACyE,CAAC;AAE5F,wDAAwD;AACxD,eAAO,MAAM,YAAY,uFAC6D,CAAC;AAEvF,uEAAuE;AACvE,eAAO,MAAM,aAAa,iGACsE,CAAC;AAEjG,qGAAqG;AACrG,eAAO,MAAM,WAAW,qaAC4Y,CAAC;AAEra,oEAAoE;AACpE,eAAO,MAAM,UAAU,iGACyE,CAAC;AAEjG,kGAAkG;AAClG,eAAO,MAAM,WAAW,0YACiX,CAAC;AAO1Y;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAM/F"}
@@ -12,11 +12,11 @@ export const surfaceField = 'w-full rounded-md border border-separator bg-field
12
12
  /** Transparent overlay checkbox input sized to {@link checkboxBox}. */
13
13
  export const checkboxInput = 'peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed';
14
14
  /** Custom checkbox box styled via `peer-checked` / `peer-focus-visible` on {@link checkboxInput}. */
15
- export const checkboxBox = 'flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border border-separator bg-field text-white peer-checked:border-accent peer-checked:bg-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&>svg]:opacity-0 peer-checked:[&>svg]:opacity-100';
15
+ export const checkboxBox = 'pointer-events-none flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border border-separator bg-field text-white peer-checked:border-accent peer-checked:bg-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&>svg]:opacity-0 peer-checked:[&>svg]:opacity-100';
16
16
  /** Transparent overlay radio input sized to {@link radioCircle}. */
17
17
  export const radioInput = 'peer absolute inset-0 m-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed';
18
18
  /** Custom radio circle styled via `peer-checked` / `peer-focus-visible` on {@link radioInput}. */
19
- export const radioCircle = 'flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full border border-separator bg-field peer-checked:border-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&>span]:opacity-0 peer-checked:[&>span]:opacity-100';
19
+ export const radioCircle = 'pointer-events-none flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full border border-separator bg-field peer-checked:border-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-accent peer-disabled:cursor-not-allowed peer-disabled:opacity-50 [&>span]:opacity-0 peer-checked:[&>span]:opacity-100';
20
20
  const VARIANT_CLASSES = {
21
21
  control: field,
22
22
  surface: surfaceField
@@ -0,0 +1,83 @@
1
+ /**
2
+ * In-memory contribution registry for one plugin activation inside an isolated webview.
3
+ */
4
+
5
+ /** @type {Map<string, unknown>} */
6
+ const components = new Map();
7
+
8
+ /** @type {Map<string, unknown>} */
9
+ const indicators = new Map();
10
+
11
+ /** @type {Map<string, unknown>} */
12
+ const headerActions = new Map();
13
+
14
+ /**
15
+ * Stores a UI contribution component keyed by manifest contribution id.
16
+ *
17
+ * @param {string} kind - Contribution bucket (requestTabs, sidebarPanels, etc.).
18
+ * @param {string} contributionId - Manifest contributes.* id.
19
+ * @param {unknown} component - React component reference.
20
+ */
21
+ export function registerContributionComponent(kind, contributionId, component) {
22
+ components.set(`${kind}:${contributionId}`, component);
23
+ }
24
+
25
+ /**
26
+ * Stores an optional footer panel indicator component.
27
+ *
28
+ * @param {string} contributionId - Manifest footerPanels id.
29
+ * @param {unknown} component - React indicator component.
30
+ */
31
+ export function registerContributionIndicator(contributionId, component) {
32
+ indicators.set(contributionId, component);
33
+ }
34
+
35
+ /**
36
+ * Stores an optional sidebar section headerActions component.
37
+ *
38
+ * @param {string} contributionId - Manifest sidebarSections id.
39
+ * @param {unknown} component - React headerActions component.
40
+ */
41
+ export function registerContributionHeaderActions(contributionId, component) {
42
+ headerActions.set(contributionId, component);
43
+ }
44
+
45
+ /**
46
+ * Returns a registered contribution component.
47
+ *
48
+ * @param {string} kind - Contribution bucket.
49
+ * @param {string} contributionId - Manifest contributes.* id.
50
+ * @returns {unknown | undefined}
51
+ */
52
+ export function getContributionComponent(kind, contributionId) {
53
+ return components.get(`${kind}:${contributionId}`);
54
+ }
55
+
56
+ /**
57
+ * Returns a registered footer panel indicator component.
58
+ *
59
+ * @param {string} contributionId - Manifest footerPanels id.
60
+ * @returns {unknown | undefined}
61
+ */
62
+ export function getContributionIndicator(contributionId) {
63
+ return indicators.get(contributionId);
64
+ }
65
+
66
+ /**
67
+ * Returns a registered sidebar section headerActions component.
68
+ *
69
+ * @param {string} contributionId - Manifest sidebarSections id.
70
+ * @returns {unknown | undefined}
71
+ */
72
+ export function getContributionHeaderActions(contributionId) {
73
+ return headerActions.get(contributionId);
74
+ }
75
+
76
+ /**
77
+ * Clears all stored contribution components for a fresh activation.
78
+ */
79
+ export function clearContributionRegistry() {
80
+ components.clear();
81
+ indicators.clear();
82
+ headerActions.clear();
83
+ }
@@ -0,0 +1,678 @@
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
+ /**
16
+ * Parses a view-host role string into agent vs view contribution id.
17
+ *
18
+ * @param {string | null | undefined} role - Role query parameter from the shell URL.
19
+ * @returns {{ mode: 'agent' | 'view'; contributionId?: string }}
20
+ */
21
+ export function parseViewHostRole(role) {
22
+ if (role == null || role === 'agent') {
23
+ return { mode: 'agent' };
24
+ }
25
+ if (role.startsWith('view:')) {
26
+ return { mode: 'view', contributionId: role.slice('view:'.length) };
27
+ }
28
+ if (role === 'view') {
29
+ return { mode: 'view' };
30
+ }
31
+ return { mode: 'agent' };
32
+ }
33
+
34
+ /**
35
+ * Builds a disposable handle that removes one command handler registration.
36
+ *
37
+ * @param {string} scopedId - Namespaced command id.
38
+ * @param {(...args: unknown[]) => void | Promise<void>} handler - Handler to remove.
39
+ * @returns {{ dispose: () => void }}
40
+ */
41
+ function createCommandDisposable(scopedId, handler) {
42
+ return {
43
+ dispose: () => {
44
+ const handlers = commandHandlers.get(scopedId);
45
+ if (!handlers) {
46
+ return;
47
+ }
48
+ handlers.delete(handler);
49
+ if (handlers.size === 0) {
50
+ commandHandlers.delete(scopedId);
51
+ }
52
+ }
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Executes a registered plugin command inside this webview realm.
58
+ *
59
+ * @param {string} pluginId - Plugin manifest id.
60
+ * @param {string} commandId - Command id declared in the manifest.
61
+ * @param {unknown[]} args - Arguments passed to the handler.
62
+ */
63
+ export async function executeLocalPluginCommand(pluginId, commandId, ...args) {
64
+ const scopedId = `${pluginId}:${commandId}`;
65
+ const handlers = commandHandlers.get(scopedId);
66
+ if (!handlers) {
67
+ throw new Error(`Unknown plugin command: ${scopedId}`);
68
+ }
69
+ for (const handler of handlers) {
70
+ await handler(...args);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Creates the plugin activation context backed by the main-process broker.
76
+ *
77
+ * @param {object} options - Activation options parsed from the shell URL.
78
+ * @param {string} options.pluginId - Plugin manifest id.
79
+ * @param {'agent' | 'view'} options.mode - Agent runs logic; view renders one contribution.
80
+ * @param {string | undefined} options.contributionId - Manifest contribution id for view mode.
81
+ * @param {typeof import('react')} options.react - React namespace for this webview realm.
82
+ * @param {Record<string, unknown>} options.manifest - Parsed plugin manifest.
83
+ * @returns {import('../types').PluginContext}
84
+ */
85
+ export function createBridgedPluginContext({ pluginId, mode, contributionId, react, manifest }) {
86
+ const subscriptions = [];
87
+ const permissions = new Set(manifest.permissions ?? []);
88
+ const isAgent = mode === 'agent';
89
+
90
+ /**
91
+ * Asserts that the plugin declared a permission in its manifest.
92
+ *
93
+ * @param {string} permission - Required permission flag.
94
+ */
95
+ const assertPermission = (permission) => {
96
+ if (!permissions.has(permission)) {
97
+ throw new Error(`Plugin ${pluginId} lacks permission: ${permission}`);
98
+ }
99
+ };
100
+
101
+ /**
102
+ * Asserts UI permission for contribution registration.
103
+ */
104
+ const assertUi = () => assertPermission('ui');
105
+
106
+ /**
107
+ * Returns whether UI registration should run in this webview role.
108
+ */
109
+ const canRegisterUi = () => isAgent || mode === 'view';
110
+
111
+ /**
112
+ * Asserts that a contribution id is declared in manifest.contributes.
113
+ *
114
+ * @param {string} key - contributes.* key.
115
+ * @param {string} id - Contribution id.
116
+ */
117
+ const assertManifestContribution = (key, id) => {
118
+ const entries = manifest.contributes?.[key];
119
+ if (!Array.isArray(entries) || !entries.some((entry) => entry.id === id)) {
120
+ throw new Error(`Contribution id "${id}" is not declared in manifest.contributes.${key}.`);
121
+ }
122
+ };
123
+
124
+ /**
125
+ * Asserts that a menu command is declared in manifest.contributes.menus.
126
+ *
127
+ * @param {string} command - Command id referenced by the menu item.
128
+ */
129
+ const assertManifestMenuCommand = (command) => {
130
+ const entries = manifest.contributes?.menus;
131
+ if (!Array.isArray(entries) || !entries.some((entry) => entry.command === command)) {
132
+ throw new Error(`Command "${command}" is not declared in manifest.contributes.menus.`);
133
+ }
134
+ };
135
+
136
+ /**
137
+ * Registers a UI contribution locally and forwards metadata to the host when agent.
138
+ *
139
+ * @param {string} kind - Contribution bucket key.
140
+ * @param {string} id - Manifest contribution id.
141
+ * @param {Record<string, unknown>} metadata - Serializable metadata for the host registry.
142
+ * @param {unknown} component - React component registered in this realm.
143
+ * @param {object} [options] - Optional indicator/headerActions components.
144
+ * @param {unknown} [options.indicator] - Footer panel indicator component.
145
+ * @param {unknown} [options.headerActions] - Sidebar section header actions component.
146
+ * @returns {{ dispose: () => void }}
147
+ */
148
+ const registerUiContribution = (kind, id, metadata, component, options = {}) => {
149
+ assertUi();
150
+ registerContributionComponent(kind, id, component);
151
+ if (options.indicator) {
152
+ registerContributionIndicator(id, options.indicator);
153
+ }
154
+ if (options.headerActions) {
155
+ registerContributionHeaderActions(id, options.headerActions);
156
+ }
157
+
158
+ if (isAgent) {
159
+ void bridgeInvoke('registerContribution', {
160
+ kind,
161
+ contribution: { pluginId, ...metadata }
162
+ });
163
+ }
164
+
165
+ return {
166
+ dispose: () => {
167
+ if (isAgent) {
168
+ void bridgeInvoke('unregisterContribution', { kind, contributionId: id });
169
+ }
170
+ }
171
+ };
172
+ };
173
+
174
+ /**
175
+ * No-op UI registration in view webviews (agent owns metadata).
176
+ *
177
+ * @returns {{ dispose: () => void }}
178
+ */
179
+ const noopDisposable = () => ({ dispose: () => {} });
180
+
181
+ setHostReact(react);
182
+
183
+ return {
184
+ pluginId,
185
+ react,
186
+ subscriptions,
187
+ storage: {
188
+ get: async (key) => {
189
+ assertPermission('storage');
190
+ return bridgeInvoke('storage.get', { key });
191
+ },
192
+ set: async (key, value) => {
193
+ assertPermission('storage');
194
+ await bridgeInvoke('storage.set', { key, value });
195
+ }
196
+ },
197
+ database: {
198
+ query: (mode, sql, params, txnId) => {
199
+ assertPermission('database');
200
+ return bridgeInvoke('database.query', { mode, sql, params, txnId });
201
+ },
202
+ exec: (sql) => {
203
+ assertPermission('database');
204
+ return bridgeInvoke('database.exec', { sql });
205
+ },
206
+ beginTransaction: () => {
207
+ assertPermission('database');
208
+ return bridgeInvoke('database.beginTransaction');
209
+ },
210
+ endTransaction: (txnId, action) => {
211
+ assertPermission('database');
212
+ return bridgeInvoke('database.endTransaction', { txnId, action });
213
+ }
214
+ },
215
+ fs: {
216
+ pickFile: async (options) => {
217
+ assertPermission('filesystem:pick');
218
+ return bridgeInvoke('fs.pickFile', { options });
219
+ },
220
+ pickDirectory: async (defaultPath) => {
221
+ assertPermission('filesystem:pick');
222
+ return bridgeInvoke('fs.pickDirectory', { defaultPath: defaultPath ?? '' });
223
+ },
224
+ saveFile: async (content, options) => {
225
+ assertPermission('filesystem:pick');
226
+ return bridgeInvoke('fs.saveFile', { content, options });
227
+ },
228
+ readFile: async (path) => {
229
+ assertPermission('filesystem:read');
230
+ return bridgeInvoke('fs.readFile', { path });
231
+ },
232
+ writeFile: async (path, content) => {
233
+ assertPermission('filesystem:write');
234
+ await bridgeInvoke('fs.writeFile', { path, content });
235
+ },
236
+ watchFile: (path, listener) => {
237
+ assertPermission('filesystem:read');
238
+ const unsubscribe = bridgeOn(`fs.watch:${path}`, () => {
239
+ listener(path);
240
+ });
241
+ void bridgeInvoke('fs.watchFile', { path });
242
+ return { dispose: unsubscribe };
243
+ }
244
+ },
245
+ commands: {
246
+ register: (id, handler) => {
247
+ assertUi();
248
+ assertManifestContribution('commands', id);
249
+ if (!isAgent) {
250
+ return noopDisposable();
251
+ }
252
+ const scopedId = `${pluginId}:${id}`;
253
+ const handlers = commandHandlers.get(scopedId) ?? new Set();
254
+ handlers.add(handler);
255
+ commandHandlers.set(scopedId, handlers);
256
+ return createCommandDisposable(scopedId, handler);
257
+ },
258
+ execute: async (id, ...args) => {
259
+ const [ownerId, commandId] = id.includes(':') ? id.split(':', 2) : [pluginId, id];
260
+ if (ownerId === pluginId) {
261
+ await executeLocalPluginCommand(ownerId, commandId, ...args);
262
+ return;
263
+ }
264
+ await bridgeInvoke('commands.executeRemote', { pluginId: ownerId, commandId, args });
265
+ }
266
+ },
267
+ themes: {
268
+ register: (theme) => {
269
+ assertUi();
270
+ assertManifestContribution('themes', theme.id);
271
+ if (!isAgent) {
272
+ return noopDisposable();
273
+ }
274
+ void bridgeInvoke('themes.register', { theme });
275
+ return {
276
+ dispose: () => {
277
+ void bridgeInvoke('themes.unregister', { themeId: theme.id });
278
+ }
279
+ };
280
+ },
281
+ getActive: async () => bridgeInvoke('themes.getActive'),
282
+ onDidChange: (listener) => {
283
+ const unsubscribe = bridgeOn('themes.changed', listener);
284
+ void bridgeInvoke('themes.getActive').then(listener);
285
+ return { dispose: unsubscribe };
286
+ }
287
+ },
288
+ ui: {
289
+ registerSettingsSection: (section) => {
290
+ assertManifestContribution('settingsSections', section.id);
291
+ if (!canRegisterUi()) {
292
+ return noopDisposable();
293
+ }
294
+ return registerUiContribution(
295
+ 'settingsSections',
296
+ section.id,
297
+ {
298
+ id: `plugin:${pluginId}:${section.id}`,
299
+ title: section.title,
300
+ contributionId: section.id
301
+ },
302
+ section.Component
303
+ );
304
+ },
305
+ registerSidebarPanel: (panel) => {
306
+ assertManifestContribution('sidebarPanels', panel.id);
307
+ if (!canRegisterUi()) {
308
+ return noopDisposable();
309
+ }
310
+ return registerUiContribution(
311
+ 'sidebarPanels',
312
+ panel.id,
313
+ {
314
+ id: `plugin:${pluginId}:${panel.id}`,
315
+ title: panel.title,
316
+ icon: panel.icon,
317
+ order: panel.order,
318
+ contributionId: panel.id
319
+ },
320
+ panel.Component
321
+ );
322
+ },
323
+ registerSidebarSection: (section) => {
324
+ assertManifestContribution('sidebarSections', section.id);
325
+ if (!canRegisterUi()) {
326
+ return noopDisposable();
327
+ }
328
+ return registerUiContribution(
329
+ 'sidebarSections',
330
+ section.id,
331
+ {
332
+ id: `plugin:${pluginId}:${section.id}`,
333
+ title: section.title,
334
+ order: section.order,
335
+ contributionId: section.id,
336
+ hasHeaderActions: Boolean(section.headerActions)
337
+ },
338
+ section.Component,
339
+ { headerActions: section.headerActions }
340
+ );
341
+ },
342
+ registerMainView: (view) => {
343
+ assertManifestContribution('mainViews', view.id);
344
+ if (!canRegisterUi()) {
345
+ return noopDisposable();
346
+ }
347
+ return registerUiContribution(
348
+ 'mainViews',
349
+ view.id,
350
+ { id: `plugin:${pluginId}:${view.id}`, title: view.title, contributionId: view.id },
351
+ view.Component
352
+ );
353
+ },
354
+ registerRequestTab: (tab) => {
355
+ assertManifestContribution('requestTabs', tab.id);
356
+ if (!canRegisterUi()) {
357
+ return noopDisposable();
358
+ }
359
+ return registerUiContribution(
360
+ 'requestTabs',
361
+ tab.id,
362
+ {
363
+ id: `plugin:${pluginId}:${tab.id}`,
364
+ title: tab.title,
365
+ order: tab.order,
366
+ contributionId: tab.id
367
+ },
368
+ tab.Component
369
+ );
370
+ },
371
+ registerResponseTab: (tab) => {
372
+ assertManifestContribution('responseTabs', tab.id);
373
+ if (!canRegisterUi()) {
374
+ return noopDisposable();
375
+ }
376
+ return registerUiContribution(
377
+ 'responseTabs',
378
+ tab.id,
379
+ {
380
+ id: `plugin:${pluginId}:${tab.id}`,
381
+ title: tab.title,
382
+ order: tab.order,
383
+ when: tab.when,
384
+ contributionId: tab.id
385
+ },
386
+ tab.Component
387
+ );
388
+ },
389
+ registerCollectionSettingsTab: (tab) => {
390
+ assertManifestContribution('collectionSettingsTabs', tab.id);
391
+ if (!canRegisterUi()) {
392
+ return noopDisposable();
393
+ }
394
+ return registerUiContribution(
395
+ 'collectionSettingsTabs',
396
+ tab.id,
397
+ {
398
+ id: `plugin:${pluginId}:${tab.id}`,
399
+ title: tab.title,
400
+ order: tab.order,
401
+ contributionId: tab.id
402
+ },
403
+ tab.Component
404
+ );
405
+ },
406
+ registerFooterPanel: (panel) => {
407
+ assertManifestContribution('footerPanels', panel.id);
408
+ if (!canRegisterUi()) {
409
+ return noopDisposable();
410
+ }
411
+ return registerUiContribution(
412
+ 'footerPanels',
413
+ panel.id,
414
+ {
415
+ id: `plugin:${pluginId}:${panel.id}`,
416
+ title: panel.title,
417
+ contributionId: panel.id,
418
+ hasIndicator: Boolean(panel.Indicator)
419
+ },
420
+ panel.Component,
421
+ { indicator: panel.Indicator }
422
+ );
423
+ },
424
+ registerMenuItem: (item) => {
425
+ assertManifestMenuCommand(item.command);
426
+ if (!isAgent) {
427
+ return noopDisposable();
428
+ }
429
+ void bridgeInvoke('registerContribution', {
430
+ kind: 'menuItems',
431
+ contribution: {
432
+ pluginId,
433
+ menu: item.menu,
434
+ command: item.command,
435
+ label: item.label,
436
+ group: item.group,
437
+ order: item.order
438
+ }
439
+ });
440
+ return {
441
+ dispose: () => {
442
+ void bridgeInvoke('unregisterContribution', {
443
+ kind: 'menuItems',
444
+ contributionId: `${item.menu}:${item.command}`
445
+ });
446
+ }
447
+ };
448
+ },
449
+ registerRequestToolbarAction: (action) => {
450
+ assertManifestContribution('requestToolbarActions', action.id);
451
+ if (!isAgent) {
452
+ return noopDisposable();
453
+ }
454
+ void bridgeInvoke('registerContribution', {
455
+ kind: 'requestToolbarActions',
456
+ contribution: {
457
+ pluginId,
458
+ id: action.id,
459
+ title: action.title,
460
+ command: action.command,
461
+ icon: action.icon,
462
+ order: action.order
463
+ }
464
+ });
465
+ return {
466
+ dispose: () => {
467
+ void bridgeInvoke('unregisterContribution', {
468
+ kind: 'requestToolbarActions',
469
+ contributionId: action.id
470
+ });
471
+ }
472
+ };
473
+ },
474
+ registerContextMenuItem: (item) => {
475
+ assertManifestContribution('contextMenus', item.id);
476
+ if (!isAgent) {
477
+ return noopDisposable();
478
+ }
479
+ void bridgeInvoke('registerContribution', {
480
+ kind: 'contextMenuItems',
481
+ contribution: {
482
+ pluginId,
483
+ id: item.id,
484
+ title: item.title,
485
+ command: item.command,
486
+ when: item.when,
487
+ group: item.group,
488
+ order: item.order
489
+ }
490
+ });
491
+ return {
492
+ dispose: () => {
493
+ void bridgeInvoke('unregisterContribution', {
494
+ kind: 'contextMenuItems',
495
+ contributionId: item.id
496
+ });
497
+ }
498
+ };
499
+ },
500
+ registerStatusBarItem: (item) => {
501
+ assertManifestContribution('statusBarItems', item.id);
502
+ if (!canRegisterUi()) {
503
+ return noopDisposable();
504
+ }
505
+ return registerUiContribution(
506
+ 'statusBarItems',
507
+ item.id,
508
+ {
509
+ id: `plugin:${pluginId}:${item.id}`,
510
+ alignment: item.alignment,
511
+ order: item.order,
512
+ contributionId: item.id
513
+ },
514
+ item.Component
515
+ );
516
+ },
517
+ showToast: (message, options) => {
518
+ assertUi();
519
+ void bridgeInvoke('ui.showToast', { message, options });
520
+ }
521
+ },
522
+ http: {
523
+ onAfterSend: (handler) => {
524
+ assertPermission('http');
525
+ if (!isAgent) {
526
+ return noopDisposable();
527
+ }
528
+ const unsubscribe = bridgeOn('http.afterSend', handler);
529
+ return { dispose: unsubscribe };
530
+ }
531
+ },
532
+ ipc: {
533
+ invoke: async (channel, ...args) => {
534
+ assertPermission('ipc');
535
+ return bridgeInvoke('ipc.invoke', { channel, args });
536
+ }
537
+ },
538
+ host: {
539
+ openRequestDraft: async (payload) => {
540
+ assertUi();
541
+ await bridgeInvoke('host.openRequestDraft', { payload });
542
+ },
543
+ loadRequest: async (requestId) => {
544
+ assertUi();
545
+ await bridgeInvoke('host.loadRequest', { requestId });
546
+ },
547
+ sendRequest: async () => {
548
+ assertUi();
549
+ await bridgeInvoke('host.sendRequest');
550
+ },
551
+ createEnvironmentWithVariables: async (name, variables) => {
552
+ assertUi();
553
+ return bridgeInvoke('host.createEnvironmentWithVariables', { name, variables });
554
+ },
555
+ updateEnvironmentVariables: async (environmentId, variables) => {
556
+ assertUi();
557
+ await bridgeInvoke('host.updateEnvironmentVariables', { environmentId, variables });
558
+ },
559
+ createCollection: async (payload) => {
560
+ assertUi();
561
+ return bridgeInvoke('host.createCollection', { payload });
562
+ },
563
+ listCollectionRequests: async (collectionId, folderId) => {
564
+ assertUi();
565
+ return bridgeInvoke('host.listCollectionRequests', { collectionId, folderId });
566
+ },
567
+ getCollectionMetadata: async (collectionId) => {
568
+ assertUi();
569
+ return bridgeInvoke('host.getCollectionMetadata', { collectionId });
570
+ },
571
+ logRequestToConsole: async (payload) => {
572
+ assertUi();
573
+ await bridgeInvoke('host.logRequestToConsole', { payload });
574
+ },
575
+ sendHttpRequest: async (input) => {
576
+ assertUi();
577
+ return bridgeInvoke('host.sendHttpRequest', { input });
578
+ },
579
+ clearResponse: async () => {
580
+ assertUi();
581
+ await bridgeInvoke('host.clearResponse');
582
+ }
583
+ }
584
+ };
585
+ }
586
+
587
+ /**
588
+ * Maps a manifest contributes key to the contribution registry bucket name.
589
+ *
590
+ * @param {string} contributionId - Manifest contribution id for view mode.
591
+ * @returns {string | undefined}
592
+ */
593
+ export function resolveContributionKindFromUrl(contributionId, searchParams) {
594
+ const kind = searchParams.get('kind');
595
+ return kind ?? undefined;
596
+ }
597
+
598
+ /**
599
+ * Mounts one contribution component into the view webview root element.
600
+ *
601
+ * @param {object} options - Mount options.
602
+ * @param {typeof import('react')} options.react - React namespace.
603
+ * @param {typeof import('react-dom/client')} options.reactDom - React DOM client namespace.
604
+ * @param {string} options.kind - Contribution bucket.
605
+ * @param {string} options.contributionId - Manifest contribution id.
606
+ * @param {HTMLElement} options.root - DOM mount target.
607
+ * @param {'content' | 'headerActions' | 'indicator'} [options.slot] - Contribution sub-slot.
608
+ * @returns {() => void} Cleanup function that unmounts the React root.
609
+ */
610
+ export function mountContributionView({
611
+ react,
612
+ reactDom,
613
+ kind,
614
+ contributionId,
615
+ root,
616
+ slot = 'content'
617
+ }) {
618
+ let Component;
619
+ if (slot === 'headerActions') {
620
+ Component = getContributionHeaderActions(contributionId);
621
+ } else if (slot === 'indicator') {
622
+ Component = getContributionIndicator(contributionId);
623
+ } else {
624
+ Component = getContributionComponent(kind, contributionId);
625
+ }
626
+ if (Component == null) {
627
+ throw new Error(`Unknown plugin contribution: ${kind}:${contributionId}`);
628
+ }
629
+
630
+ /** @type {unknown} */
631
+ let currentContext = null;
632
+
633
+ const needsContext =
634
+ kind === 'requestTabs' || kind === 'responseTabs' || kind === 'collectionSettingsTabs';
635
+
636
+ const reactRoot = reactDom.createRoot(root);
637
+
638
+ /**
639
+ * Renders the contribution with the latest pushed context snapshot. For
640
+ * context-bearing contributions the first render is deferred until a context
641
+ * snapshot is available so the component never receives a null context.
642
+ */
643
+ const render = () => {
644
+ if (needsContext && currentContext == null) {
645
+ return;
646
+ }
647
+ const element = needsContext
648
+ ? react.createElement(Component, { context: currentContext })
649
+ : react.createElement(Component);
650
+ reactRoot.render(element);
651
+ };
652
+
653
+ const unsubscribe = bridgeOn('view.context', (payload) => {
654
+ currentContext = payload;
655
+ render();
656
+ });
657
+
658
+ if (needsContext) {
659
+ // The host pushes context on mount/dom-ready, which can race ahead of this
660
+ // subscription, so pull the current snapshot now that we are listening.
661
+ void bridgeInvoke('view.getContext')
662
+ .then((context) => {
663
+ if (context != null && currentContext == null) {
664
+ currentContext = context;
665
+ render();
666
+ }
667
+ })
668
+ .catch(() => {});
669
+ }
670
+
671
+ render();
672
+
673
+ return () => {
674
+ unsubscribe();
675
+ };
676
+ }
677
+
678
+ export { getContributionComponent, getContributionHeaderActions, getContributionIndicator };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Preload bridge exposed to isolated plugin webContents.
3
+ */
4
+ export interface HcBridgeGlobal {
5
+ /**
6
+ * Invokes a permission-checked broker operation in the main process.
7
+ */
8
+ invoke(op: string, payload?: unknown): Promise<unknown>;
9
+
10
+ /**
11
+ * Subscribes to preload push events from the main process.
12
+ */
13
+ on(channel: string, listener: (payload: unknown) => void): () => void;
14
+
15
+ /** @internal Prevents duplicate handler installation. */
16
+ __handlersInstalled?: boolean;
17
+ }
18
+
19
+ declare global {
20
+ /**
21
+ * Scoped plugin bridge installed by the HarborClient plugin preload.
22
+ */
23
+ var hcBridge: HcBridgeGlobal | undefined;
24
+ }
25
+
26
+ export {};
@@ -0,0 +1,104 @@
1
+ /** @type {number} */
2
+ let nextRequestId = 1;
3
+
4
+ /** @type {Map<number, { resolve: (value: unknown) => void; reject: (reason?: unknown) => void }>} */
5
+ const pendingRequests = new Map();
6
+
7
+ /** @type {Map<string, Set<(payload: unknown) => void>>} */
8
+ const eventListeners = new Map();
9
+
10
+ /**
11
+ * Returns the preload-provided bridge or throws when the plugin preload is missing.
12
+ *
13
+ * @returns {import('./hcBridge.d.ts').HcBridgeGlobal}
14
+ */
15
+ export function requireHcBridge() {
16
+ const bridge = globalThis.hcBridge;
17
+ if (bridge == null) {
18
+ throw new Error('Plugin hcBridge is not available. Is the plugin preload loaded?');
19
+ }
20
+ return bridge;
21
+ }
22
+
23
+ /**
24
+ * Invokes a permission-checked host operation through the main-process broker.
25
+ *
26
+ * @param {string} op - Broker operation name.
27
+ * @param {unknown} [payload] - Serializable operation payload.
28
+ * @returns {Promise<unknown>}
29
+ */
30
+ export async function bridgeInvoke(op, payload) {
31
+ return requireHcBridge().invoke(op, payload);
32
+ }
33
+
34
+ /**
35
+ * Subscribes to broker push events (theme changes, tab context updates, etc.).
36
+ *
37
+ * @param {string} channel - Event channel name.
38
+ * @param {(payload: unknown) => void} listener - Event handler.
39
+ * @returns {() => void} Unsubscribe function.
40
+ */
41
+ export function bridgeOn(channel, listener) {
42
+ const bridge = requireHcBridge();
43
+ return bridge.on(channel, listener);
44
+ }
45
+
46
+ /**
47
+ * Handles correlated replies from the main-process broker.
48
+ *
49
+ * @param {{ id: number; ok: boolean; result?: unknown; error?: string }} message - Broker reply.
50
+ */
51
+ export function handleBridgeReply(message) {
52
+ const pending = pendingRequests.get(message.id);
53
+ if (!pending) {
54
+ return;
55
+ }
56
+ pendingRequests.delete(message.id);
57
+ if (message.ok) {
58
+ pending.resolve(message.result);
59
+ return;
60
+ }
61
+ pending.reject(new Error(message.error ?? 'Plugin bridge invocation failed.'));
62
+ }
63
+
64
+ /**
65
+ * Dispatches a broker push event to registered listeners.
66
+ *
67
+ * @param {string} channel - Event channel name.
68
+ * @param {unknown} payload - Event payload.
69
+ */
70
+ export function dispatchBridgeEvent(channel, payload) {
71
+ const listeners = eventListeners.get(channel);
72
+ if (!listeners) {
73
+ return;
74
+ }
75
+ for (const listener of listeners) {
76
+ listener(payload);
77
+ }
78
+ }
79
+
80
+ /** Guards repeated bootstrap calls without mutating the frozen bridge object. */
81
+ let handlersInstalled = false;
82
+
83
+ /**
84
+ * Prepares the bridge for use inside a plugin webview.
85
+ *
86
+ * The HarborClient plugin preload exposes a high-level bridge: `invoke` is
87
+ * promise-based and `on(channel, listener)` dispatches host push events by
88
+ * channel. `bridgeInvoke` and `bridgeOn` delegate straight to those methods,
89
+ * so no reply/event correlation layer is needed here.
90
+ *
91
+ * The bridge is exposed through Electron `contextBridge`, which makes it
92
+ * non-extensible with read-only properties. We therefore must not assign to it
93
+ * (for example `bridge.__handlersInstalled` or reassigning `bridge.on`); doing
94
+ * so throws "object is not extensible". Idempotency is tracked module-side.
95
+ *
96
+ * Called once from the view host bootstrap before plugin activation.
97
+ */
98
+ export function installBridgeHandlers() {
99
+ if (handlersInstalled) {
100
+ return;
101
+ }
102
+ requireHcBridge();
103
+ handlersInstalled = true;
104
+ }
@@ -0,0 +1 @@
1
+ export { bootstrapViewHost, applyHostTheme } from '../viewHost.js';
@@ -0,0 +1,3 @@
1
+ import { bootstrapViewHost, applyHostTheme } from './viewHost.js';
2
+
3
+ export { bootstrapViewHost, applyHostTheme };
@@ -0,0 +1,121 @@
1
+ import { installBridgeHandlers, bridgeOn } from './hcBridge.js';
2
+ import { clearContributionRegistry } from './contributionRegistry.js';
3
+ import {
4
+ createBridgedPluginContext,
5
+ executeLocalPluginCommand,
6
+ mountContributionView,
7
+ parseViewHostRole,
8
+ resolveContributionKindFromUrl
9
+ } from './createBridgedPluginContext.js';
10
+
11
+ /**
12
+ * Bootstraps an isolated plugin webview shell.
13
+ *
14
+ * Loads the plugin renderer bundle, runs activate(hc), and either registers
15
+ * contributions with the host (agent role) or mounts one UI contribution (view role).
16
+ *
17
+ * @param {object} [options] - Bootstrap options; defaults are parsed from location.search.
18
+ * @param {string} [options.pluginId] - Plugin manifest id from the shell URL hostname.
19
+ * @param {string | null} [options.role] - Agent or view role query parameter.
20
+ * @param {string | null} [options.contrib] - Manifest contribution id for view mode.
21
+ * @param {string | null} [options.kind] - Contribution bucket for view mode.
22
+ */
23
+ export async function bootstrapViewHost(options = {}) {
24
+ installBridgeHandlers();
25
+
26
+ const url = new URL(globalThis.location.href);
27
+ const pluginId = options.pluginId ?? url.hostname;
28
+ const roleParam = options.role ?? url.searchParams.get('role');
29
+ const contrib = options.contrib ?? url.searchParams.get('contrib');
30
+ const kind =
31
+ options.kind ??
32
+ url.searchParams.get('kind') ??
33
+ resolveContributionKindFromUrl(contrib, url.searchParams);
34
+ const slot = url.searchParams.get('slot') ?? 'content';
35
+
36
+ const parsedRole = parseViewHostRole(
37
+ roleParam === 'view' && contrib ? `view:${contrib}` : roleParam
38
+ );
39
+ const contributionId = parsedRole.contributionId ?? contrib ?? undefined;
40
+
41
+ const [{ default: React }, { default: ReactDOM }, { default: ReactDOMClient }] =
42
+ await Promise.all([
43
+ import('harbor-plugin://host/react.js'),
44
+ import('harbor-plugin://host/react-dom.js'),
45
+ import('harbor-plugin://host/react-dom-client.js')
46
+ ]);
47
+
48
+ const manifest = await fetch(`harbor-plugin://${pluginId}/manifest.json`).then((response) =>
49
+ response.json()
50
+ );
51
+
52
+ clearContributionRegistry();
53
+
54
+ const bundleUrl = `harbor-plugin://${pluginId}/bundle.js`;
55
+ const module = await import(/* @vite-ignore */ bundleUrl);
56
+ if (typeof module.activate !== 'function') {
57
+ throw new Error(`Plugin ${pluginId} renderer entry must export activate(hc).`);
58
+ }
59
+
60
+ const hc = createBridgedPluginContext({
61
+ pluginId,
62
+ mode: parsedRole.mode,
63
+ contributionId,
64
+ react: React,
65
+ manifest
66
+ });
67
+
68
+ await module.activate(hc);
69
+
70
+ if (parsedRole.mode === 'agent') {
71
+ bridgeOn('commands.execute', async (payload) => {
72
+ const { commandId, args } = payload ?? {};
73
+ await executeLocalPluginCommand(pluginId, commandId, ...(args ?? []));
74
+ });
75
+ await fetch(`harbor-plugin://${pluginId}/agent-ready`, { method: 'POST' });
76
+ return;
77
+ }
78
+
79
+ if (!contributionId || !kind) {
80
+ throw new Error('View webviews require contrib and kind query parameters.');
81
+ }
82
+ const root = document.getElementById('root');
83
+ if (!root) {
84
+ throw new Error('Plugin view shell is missing #root.');
85
+ }
86
+ mountContributionView({
87
+ react: React,
88
+ reactDom: ReactDOMClient,
89
+ kind,
90
+ contributionId,
91
+ root,
92
+ slot
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Applies a theme snapshot pushed from the host renderer.
98
+ *
99
+ * @param {{ dataTheme?: string | null; cssText?: string | null }} theme - Theme payload.
100
+ */
101
+ export function applyHostTheme(theme) {
102
+ if (theme.dataTheme != null) {
103
+ document.documentElement.setAttribute('data-theme', theme.dataTheme);
104
+ }
105
+ if (theme.cssText) {
106
+ let style = document.getElementById('harbor-plugin-theme-vars');
107
+ if (!style) {
108
+ style = document.createElement('style');
109
+ style.id = 'harbor-plugin-theme-vars';
110
+ document.head.appendChild(style);
111
+ }
112
+ style.textContent = theme.cssText;
113
+ }
114
+ }
115
+
116
+ if (typeof globalThis !== 'undefined') {
117
+ globalThis.__HARBORCLIENT_VIEW_HOST__ = {
118
+ bootstrapViewHost,
119
+ applyHostTheme
120
+ };
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborclient/sdk",
3
- "version": "0.6.10",
3
+ "version": "0.6.14",
4
4
  "description": "TypeScript SDK for HarborClient plugin development.",
5
5
  "keywords": [
6
6
  "harborclient",
@@ -100,6 +100,12 @@
100
100
  "require": "./dist/runtime/store.js",
101
101
  "default": "./dist/runtime/store.js"
102
102
  },
103
+ "./view-host": {
104
+ "types": "./dist/runtime/view-host/index.d.ts",
105
+ "import": "./dist/runtime/view-host/index.js",
106
+ "require": "./dist/runtime/view-host/index.js",
107
+ "default": "./dist/runtime/view-host/index.js"
108
+ },
103
109
  "./signing": {
104
110
  "types": "./dist/signing/index.d.ts",
105
111
  "import": "./dist/signing/index.js",