@gyp8735abc/dsh-mcp-manager 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,535 @@
1
+ // dsh-mcp-manager — browser half (hand-built ModuleLoader bundle).
2
+ //
3
+ // MCP + skills management in a popup opened from each workspace's MCP button
4
+ // (added to dsh-client-ui-workspace's workspace row, next to the "..." menu).
5
+ // The popup shows that workspace's own project servers, the global servers,
6
+ // manual servers, and the available skills — with PER-WORKSPACE enable/disable
7
+ // for everything. State is stored per workspace (settings ns `mcp`) by the
8
+ // host; toggles go through POST /api/dsh-mcp-manager/set, which persists and
9
+ // live-applies (MCP mounting follows the active workspace; the model-facing
10
+ // skill catalog is filtered so sessions only see enabled skills).
11
+ //
12
+ // Rendering is deliberately IMPERATIVE: the window event listener is attached
13
+ // in apply() and the popup is rendered with react-dom createRoot into a
14
+ // container on document.body — no slot mounting, no external CSS classes.
15
+ window.__ModuleLoader__.load({
16
+ id: "dsh-mcp-manager",
17
+ factory: (require) => {
18
+ var module = { exports: {} };
19
+ var exports = module.exports;
20
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
21
+ let React = require("react");
22
+ // NOTE: @deepseek-ai/dsh-client-runtime was removed on harness master
23
+ // (0.1.3-alpha.2) and the variable was never used anyway.
24
+ let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
25
+ let ReactDOM = require("react-dom");
26
+ let ReactDOMClient = require("react-dom/client");
27
+
28
+ const NS = "mcp";
29
+ const INVENTORY_URL = "/api/dsh-mcp-manager/inventory";
30
+ const ACTIVATE_URL = "/api/dsh-mcp-manager/activate";
31
+ const SET_URL = "/api/dsh-mcp-manager/set";
32
+ const OPEN_EVENT = "dsh-mcp-manager:open-workspace";
33
+ // Short root names for the host's skill `source` values, so a row says where
34
+ // the skill actually lives instead of a bare "skill" badge.
35
+ const SOURCE_TAGS = {
36
+ "project-dsh": ".dsh",
37
+ "project-claude": ".claude",
38
+ "project-agents": ".agents",
39
+ "user-dsh": "~/.dsh",
40
+ "user-claude": "~/.claude",
41
+ "user-agents": "~/.agents",
42
+ };
43
+ const inject = ["locale", "sessions", "workspaces"];
44
+
45
+ const zh = {
46
+ title: "MCP 与技能",
47
+ path: "工作区",
48
+ activeNote: "当前工作区 — MCP 切换立即生效",
49
+ inactiveNote: "非当前工作区 — 开关保存状态,切换到这个工作区时生效",
50
+ groupGlobal: "全局 MCP(~/.claude.json)",
51
+ groupProject: "项目 MCP(.mcp.json)",
52
+ groupManual: "手动 MCP(profile 配置)",
53
+ groupSkills: "技能(skills)",
54
+ groupProjectSkills: "项目技能(本工作区)",
55
+ groupGlobalSkills: "全局技能(所有工作区)",
56
+ enabled: "已启用",
57
+ disabled: "已停用",
58
+ mounted: "已连接",
59
+ unmounted: "未连接",
60
+ loading: "加载中…",
61
+ error: "无法读取配置",
62
+ empty: "这个工作区没有可用的 MCP 或技能",
63
+ close: "关闭",
64
+ };
65
+ const en = {
66
+ title: "MCP & skills",
67
+ path: "Workspace",
68
+ activeNote: "Active workspace — MCP toggles apply immediately",
69
+ inactiveNote: "Not the active workspace — toggles save state and apply when this workspace becomes active",
70
+ groupGlobal: "Global MCP (~/.claude.json)",
71
+ groupProject: "Project MCP (.mcp.json)",
72
+ groupManual: "Manual MCP (profile)",
73
+ groupSkills: "Skills",
74
+ groupProjectSkills: "Project skills (this workspace)",
75
+ groupGlobalSkills: "Global skills (all workspaces)",
76
+ enabled: "Enabled",
77
+ disabled: "Disabled",
78
+ mounted: "Connected",
79
+ unmounted: "Not connected",
80
+ loading: "Loading…",
81
+ error: "Failed to read configuration",
82
+ empty: "No MCP or skills available for this workspace",
83
+ close: "Close",
84
+ };
85
+
86
+ const h = React.createElement;
87
+
88
+ function baseName(p) {
89
+ const parts = String(p ?? "").split(/[\\/]/).filter(Boolean);
90
+ return parts.length > 0 ? parts[parts.length - 1] : String(p ?? "");
91
+ }
92
+
93
+ function samePath(a, b) {
94
+ if (!a || !b) return false;
95
+ return String(a).replace(/\\/g, "/").toLowerCase() === String(b).replace(/\\/g, "/").toLowerCase();
96
+ }
97
+
98
+ // ---- inline styles (no dependency on external CSS classes) ----
99
+ const S = {
100
+ overlay: {
101
+ position: "fixed",
102
+ inset: "0",
103
+ zIndex: "10000",
104
+ background: "rgba(0,0,0,0.45)",
105
+ display: "flex",
106
+ alignItems: "center",
107
+ justifyContent: "center",
108
+ fontFamily: "inherit",
109
+ },
110
+ dialog: {
111
+ background: "var(--dsw-alias-bg-layer-2, #202024)",
112
+ color: "var(--dsw-alias-label-primary, #eee)",
113
+ borderRadius: "12px",
114
+ padding: "20px",
115
+ width: "620px",
116
+ maxWidth: "92vw",
117
+ maxHeight: "82vh",
118
+ overflow: "auto",
119
+ boxShadow: "0 8px 40px rgba(0,0,0,0.4)",
120
+ flexDirection: "column",
121
+ gap: "10px",
122
+ display: "flex",
123
+ },
124
+ title: { margin: "0", fontSize: "16px", fontWeight: "600" },
125
+ path: { margin: "0", fontSize: "12px", color: "var(--dsw-alias-label-tertiary, #999)", wordBreak: "break-all" },
126
+ note: { margin: "0", fontSize: "12px", color: "var(--dsw-alias-state-success-primary, #4ade80)" },
127
+ noteInactive: { margin: "0", fontSize: "12px", color: "var(--dsw-alias-label-tertiary, #999)" },
128
+ group: { margin: "10px 0 4px", fontSize: "13px", fontWeight: "600", color: "var(--dsw-alias-label-secondary, #bbb)" },
129
+ row: {
130
+ border: "1px solid var(--dsw-alias-border-l2, #333)",
131
+ borderRadius: "10px",
132
+ alignItems: "center",
133
+ gap: "10px",
134
+ padding: "7px 12px",
135
+ display: "flex",
136
+ },
137
+ rowName: { fontSize: "13px", fontWeight: "500", minWidth: "0" },
138
+ rowMeta: { fontSize: "11px", color: "var(--dsw-alias-label-tertiary, #999)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: "280px" },
139
+ badge: { border: "1px solid var(--dsw-alias-border-l3, #444)", color: "var(--dsw-alias-label-secondary, #bbb)", borderRadius: "4px", padding: "0 5px", fontSize: "10px", flex: "none" },
140
+ badgeMounted: { border: "1px solid var(--dsw-alias-state-success-primary, #22c55e)", color: "var(--dsw-alias-state-success-primary, #22c55e)", borderRadius: "4px", padding: "0 5px", fontSize: "10px", flex: "none" },
141
+ badgeUnmounted: { border: "1px solid var(--dsw-alias-border-l3, #444)", color: "var(--dsw-alias-label-tertiary, #999)", borderRadius: "4px", padding: "0 5px", fontSize: "10px", flex: "none" },
142
+ spacer: { flex: "1" },
143
+ switch: {
144
+ boxSizing: "border-box",
145
+ width: "32px",
146
+ height: "18px",
147
+ borderRadius: "9px",
148
+ border: "1px solid var(--dsw-alias-border-l2, #333)",
149
+ background: "var(--dsw-alias-bg-layer-3, #2a2a2e)",
150
+ cursor: "pointer",
151
+ position: "relative",
152
+ flex: "none",
153
+ padding: "0",
154
+ },
155
+ switchOn: { background: "var(--dsw-alias-state-success-primary, #22c55e)", borderColor: "transparent" },
156
+ knob: {
157
+ position: "absolute",
158
+ top: "2px",
159
+ left: "2px",
160
+ width: "12px",
161
+ height: "12px",
162
+ borderRadius: "50%",
163
+ background: "var(--dsw-alias-label-tertiary, #999)",
164
+ transition: "transform .15s",
165
+ },
166
+ knobOn: { transform: "translateX(14px)", background: "#fff" },
167
+ closeBtn: {
168
+ height: "32px",
169
+ font: "inherit",
170
+ cursor: "pointer",
171
+ border: "none",
172
+ borderRadius: "16px",
173
+ padding: "0 14px",
174
+ fontSize: "13px",
175
+ background: "var(--dsw-alias-button-primary-fill, #4f8cff)",
176
+ color: "var(--dsw-alias-label-primary-foreground, #fff)",
177
+ alignSelf: "flex-end",
178
+ },
179
+ status: { margin: "0", fontSize: "13px", color: "var(--dsw-alias-label-tertiary, #999)" },
180
+ };
181
+
182
+ function apply(ctx) {
183
+ const t = ctx.locale.bind(NS);
184
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-mcp-manager: dictionaries");
185
+
186
+ // ---- report the active workspace to the host (debounced, on change) ----
187
+ let reported = null;
188
+ let reportTimer = null;
189
+ const reportActive = () => {
190
+ let path = null;
191
+ try {
192
+ const sessionsList = ctx.sessions.list.getSnapshot();
193
+ const currentId = sessionsList.current;
194
+ const items = ctx.workspaces.list.getSnapshot().items ?? [];
195
+ const workspace = currentId ? items.find((w) => (w.sessionIds ?? []).includes(currentId)) : void 0;
196
+ path = workspace?.path ?? null;
197
+ } catch {
198
+ /* stores not ready yet */
199
+ }
200
+ if (path === reported) return;
201
+ reported = path;
202
+ if (reportTimer !== null) clearTimeout(reportTimer);
203
+ reportTimer = setTimeout(() => {
204
+ fetch(ACTIVATE_URL, {
205
+ method: "POST",
206
+ headers: { "Content-Type": "application/json" },
207
+ body: JSON.stringify({ path }),
208
+ }).catch(() => {});
209
+ }, 300);
210
+ };
211
+ reportActive();
212
+ ctx.effect(() => {
213
+ const offSessions = ctx.sessions.list.subscribe(reportActive);
214
+ const offWorkspaces = ctx.workspaces.list.subscribe(reportActive);
215
+ return () => {
216
+ offSessions();
217
+ offWorkspaces();
218
+ if (reportTimer !== null) clearTimeout(reportTimer);
219
+ };
220
+ }, "dsh-mcp-manager: active workspace");
221
+
222
+ // ---- popup component (imperatively rendered) ----
223
+ function Popup({ path, onClose }) {
224
+ const [data, setData] = React.useState({ status: "loading", writable: false, global: [], manual: [], projects: [], skills: [], activeWorkspace: null });
225
+
226
+ const refresh = React.useCallback(() => {
227
+ let alive = true;
228
+ fetch(INVENTORY_URL + "?active=" + encodeURIComponent(path), { headers: { Accept: "application/json" } })
229
+ .then((response) => {
230
+ if (!response.ok) throw new Error("HTTP " + response.status);
231
+ return response.json();
232
+ })
233
+ .then((json) => {
234
+ if (!alive) return;
235
+ setData({
236
+ status: "ready",
237
+ writable: json.writable !== false,
238
+ global: Array.isArray(json.global) ? json.global : [],
239
+ manual: Array.isArray(json.manual) ? json.manual : [],
240
+ projects: Array.isArray(json.projects) ? json.projects : [],
241
+ skills: Array.isArray(json.skills) ? json.skills : [],
242
+ activeWorkspace: json.activeWorkspace ?? null,
243
+ });
244
+ })
245
+ .catch(() => {
246
+ if (alive) setData((current) => ({ ...current, status: "error" }));
247
+ });
248
+ return () => {
249
+ alive = false;
250
+ };
251
+ }, [path]);
252
+
253
+ React.useEffect(() => {
254
+ const cancel = refresh();
255
+ return () => cancel();
256
+ }, [refresh]);
257
+
258
+ const toggle = React.useCallback(
259
+ (kind, name, enabled) => {
260
+ fetch(SET_URL, {
261
+ method: "POST",
262
+ headers: { "Content-Type": "application/json" },
263
+ body: JSON.stringify({ path, kind, name, enabled }),
264
+ })
265
+ .then(() => refresh())
266
+ .catch(() => refresh());
267
+ },
268
+ [path, refresh]
269
+ );
270
+
271
+ const isActive = samePath(path, data.activeWorkspace);
272
+ const projectGroup = (data.projects ?? []).find((group) => samePath(group.path, path));
273
+ const projectRows = projectGroup?.servers ?? [];
274
+ // Skills are split by scope: the ones this workspace owns versus the
275
+ // user-wide ones every workspace shares. Empty groups are dropped below.
276
+ const skillRows = data.skills ?? [];
277
+ const projectSkills = skillRows.filter((row) => row.scope !== "global");
278
+ const globalSkills = skillRows.filter((row) => row.scope === "global");
279
+ const groups = [
280
+ { key: "project", label: t("groupProject"), rows: projectRows, kind: "mcp" },
281
+ { key: "global", label: t("groupGlobal"), rows: data.global ?? [], kind: "mcp" },
282
+ { key: "manual", label: t("groupManual"), rows: data.manual ?? [], kind: "mcp" },
283
+ { key: "skills-project", label: t("groupProjectSkills"), rows: projectSkills, kind: "skill" },
284
+ { key: "skills-global", label: t("groupGlobalSkills"), rows: globalSkills, kind: "skill" },
285
+ ].filter((group) => group.rows.length > 0);
286
+ const anyRows = groups.length > 0;
287
+
288
+ const rowEl = (row, group) => {
289
+ const enabled = row.enabled !== false;
290
+ const meta = row.command || row.transport || row.description || "";
291
+ const isMounted = row.mounted === true;
292
+ return h(
293
+ "div",
294
+ { style: S.row, key: group.kind + ":" + row.name + ":" + row.serverName },
295
+ h("span", { style: S.rowName }, row.name || row.serverName),
296
+ h(
297
+ "span",
298
+ { style: S.badge, title: row.source || group.key },
299
+ group.kind === "skill" ? SOURCE_TAGS[row.source] ?? "skill" : group.key
300
+ ),
301
+ group.kind === "skill"
302
+ ? null
303
+ : h(
304
+ "span",
305
+ { style: isMounted ? S.badgeMounted : S.badgeUnmounted, title: isMounted ? t("mounted") : t("unmounted") },
306
+ isMounted ? t("mounted") : t("unmounted")
307
+ ),
308
+ h("span", { style: S.rowMeta, title: meta }, meta),
309
+ h("span", { style: S.spacer }),
310
+ h(
311
+ "button",
312
+ {
313
+ type: "button",
314
+ role: "switch",
315
+ "aria-checked": enabled,
316
+ "aria-label": (row.name || row.serverName) + " (" + (enabled ? t("enabled") : t("disabled")) + ")",
317
+ style: enabled ? { ...S.switch, ...S.switchOn } : S.switch,
318
+ disabled: !data.writable,
319
+ onClick: () => toggle(group.kind, row.name || row.serverName, !enabled),
320
+ },
321
+ h("span", { style: enabled ? { ...S.knob, ...S.knobOn } : S.knob })
322
+ )
323
+ );
324
+ };
325
+
326
+ return h(
327
+ "div",
328
+ { style: S.overlay, onClick: (e) => { if (e.target === e.currentTarget) onClose(); } },
329
+ h(
330
+ "div",
331
+ { style: S.dialog, role: "dialog", "aria-modal": "true" },
332
+ h("h2", { style: S.title }, t("title") + " — " + baseName(path)),
333
+ h("p", { style: S.path }, t("path") + ": " + path),
334
+ isActive ? h("p", { style: S.note }, t("activeNote")) : h("p", { style: S.noteInactive }, t("inactiveNote")),
335
+ data.status === "loading" ? h("p", { style: S.status }, t("loading")) : null,
336
+ data.status === "error" ? h("p", { style: S.status }, t("error")) : null,
337
+ data.status === "ready" && !anyRows ? h("p", { style: S.status }, t("empty")) : null,
338
+ data.status === "ready"
339
+ ? groups.map((group) =>
340
+ h(
341
+ "div",
342
+ { key: group.key },
343
+ h("p", { style: S.group }, group.label),
344
+ group.rows.map((row) => rowEl(row, group))
345
+ )
346
+ )
347
+ : null,
348
+ h("button", { type: "button", style: S.closeBtn, onClick: onClose }, t("close"))
349
+ )
350
+ );
351
+ }
352
+
353
+ // ---- imperative open/close ----
354
+ let popupRoot = null;
355
+ let popupContainer = null;
356
+ const closePopup = () => {
357
+ try {
358
+ if (popupRoot !== null) {
359
+ popupRoot.unmount();
360
+ popupRoot = null;
361
+ } else if (popupContainer !== null && typeof ReactDOM.unmountComponentAtNode === "function") {
362
+ ReactDOM.unmountComponentAtNode(popupContainer);
363
+ }
364
+ } catch (error) {
365
+ console.error("[dsh-mcp-manager] popup unmount error", error);
366
+ }
367
+ if (popupContainer !== null) {
368
+ popupContainer.remove();
369
+ popupContainer = null;
370
+ }
371
+ };
372
+ const openPopup = (path) => {
373
+ if (popupContainer !== null) closePopup();
374
+ try {
375
+ popupContainer = document.createElement("div");
376
+ popupContainer.id = "dsh-mcp-manager-popup-root";
377
+ document.body.appendChild(popupContainer);
378
+ const element = h(Popup, { path, onClose: closePopup });
379
+ if (typeof ReactDOMClient?.createRoot === "function") {
380
+ popupRoot = ReactDOMClient.createRoot(popupContainer);
381
+ popupRoot.render(element);
382
+ } else if (typeof ReactDOM?.render === "function") {
383
+ ReactDOM.render(element, popupContainer);
384
+ } else {
385
+ console.error("[dsh-mcp-manager] no react-dom renderer available");
386
+ popupContainer.remove();
387
+ popupContainer = null;
388
+ }
389
+ } catch (error) {
390
+ console.error("[dsh-mcp-manager] popup render error", error);
391
+ if (popupContainer !== null) {
392
+ popupContainer.remove();
393
+ popupContainer = null;
394
+ }
395
+ }
396
+ };
397
+ const onOpenEvent = (event) => {
398
+ const path = event?.detail?.path ?? null;
399
+ if (typeof path === "string" && path.length > 0) openPopup(path);
400
+ };
401
+ window.addEventListener(OPEN_EVENT, onOpenEvent);
402
+ ctx.effect(() => () => window.removeEventListener(OPEN_EVENT, onOpenEvent), "dsh-mcp-manager: popup listener");
403
+
404
+ // ---- per-workspace-row MCP button, injected into the DOM ----
405
+ // 0.2.x declares no slot for workspace-row actions (only session-row ones),
406
+ // so instead of patching dsh-client-ui-workspace's bundle the button is
407
+ // injected into each row's `.rowActions` span. The row carries
408
+ // `data-row-key="workspace:<workspaceId>"`, which maps exactly onto the
409
+ // client `workspaces` list, so the workspace path needs no guessing.
410
+ const BUTTON_ATTR = "data-dsh-mcp-manager-button";
411
+ const ROW_SELECTOR = '[data-row-key^="workspace:"]';
412
+ // The button's mark: a plug seated in a rounded frame whose right edge
413
+ // opens where the cord leaves — "this workspace is wired to MCP servers".
414
+ // Drawn inline on the icons' own 16-unit grid, in currentColor, so it
415
+ // needs nothing from @deepseek-ai/dsh-client-ui-primitives (which has no
416
+ // MCP glyph) and keeps the weight of the neighbouring 1px stroke icons.
417
+ const MARK_FRAME = "M14 9.4V11a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v1.6";
418
+ const MARK_BODY = "M6.6 5.7h2a2.3 2.3 0 0 1 0 4.6h-2z";
419
+ const MARK_DETAIL = "M4.6 6.6h2M4.6 9.4h2M10.9 8H14";
420
+ /** The mark as an SVG element sized like the neighbouring row icons. */
421
+ const mcpMark = () => {
422
+ const ns = "http://www.w3.org/2000/svg";
423
+ const svg = document.createElementNS(ns, "svg");
424
+ svg.setAttribute("viewBox", "0 0 16 16");
425
+ svg.setAttribute("width", "16");
426
+ svg.setAttribute("height", "16");
427
+ svg.setAttribute("aria-hidden", "true");
428
+ svg.setAttribute("focusable", "false");
429
+ svg.style.display = "block";
430
+ const stroked = (d) => {
431
+ const path = document.createElementNS(ns, "path");
432
+ path.setAttribute("d", d);
433
+ path.setAttribute("fill", "none");
434
+ path.setAttribute("stroke", "currentColor");
435
+ path.setAttribute("stroke-width", "1");
436
+ path.setAttribute("stroke-linecap", "round");
437
+ path.setAttribute("stroke-linejoin", "round");
438
+ return path;
439
+ };
440
+ // The frame and the leads are stroked like the rest of the UI; the plug
441
+ // body is solid, as in the reference, because an outline that small
442
+ // would read as noise at 16px.
443
+ svg.appendChild(stroked(MARK_FRAME));
444
+ const body = document.createElementNS(ns, "path");
445
+ body.setAttribute("d", MARK_BODY);
446
+ body.setAttribute("fill", "currentColor");
447
+ body.setAttribute("stroke", "none");
448
+ svg.appendChild(body);
449
+ svg.appendChild(stroked(MARK_DETAIL));
450
+ return svg;
451
+ };
452
+ const injectRowButtons = () => {
453
+ const items = ctx.workspaces?.list?.getSnapshot?.()?.items ?? [];
454
+ const pathById = new Map();
455
+ for (const item of items) {
456
+ if (typeof item?.workspaceId === "string" && typeof item?.path === "string" && item.path.length > 0) {
457
+ pathById.set(item.workspaceId, item.path);
458
+ }
459
+ }
460
+ for (const row of document.querySelectorAll(ROW_SELECTOR)) {
461
+ const key = row.getAttribute("data-row-key") ?? "";
462
+ const workspaceId = key.slice("workspace:".length);
463
+ const path = workspaceId.length > 0 ? pathById.get(workspaceId) : undefined;
464
+ if (typeof path !== "string" || path.length === 0) continue;
465
+ const actions = row.querySelector('[class*="rowActions"]');
466
+ if (actions === null) continue;
467
+ if (actions.querySelector("[" + BUTTON_ATTR + "]") !== null) continue;
468
+ const sample = actions.querySelector("button");
469
+ const button = document.createElement("button");
470
+ button.type = "button";
471
+ button.setAttribute(BUTTON_ATTR, "");
472
+ button.setAttribute("aria-label", "MCP");
473
+ button.title = "MCP";
474
+ // Reuse the sibling icon buttons' own class so the shipped styles and
475
+ // hover behaviour apply without importing a CSS module.
476
+ if (sample !== null && typeof sample.className === "string" && sample.className.length > 0) {
477
+ button.className = sample.className;
478
+ } else {
479
+ button.style.cssText =
480
+ "display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;background:transparent;color:inherit;cursor:pointer;border-radius:4px;";
481
+ }
482
+ const iconBox = document.createElement("span");
483
+ iconBox.style.cssText = "display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;";
484
+ iconBox.appendChild(mcpMark());
485
+ button.appendChild(iconBox);
486
+ button.addEventListener("click", (event) => {
487
+ event.preventDefault();
488
+ event.stopPropagation();
489
+ window.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: { workspaceId, path } }));
490
+ });
491
+ actions.insertBefore(button, actions.firstChild);
492
+ }
493
+ };
494
+ let injectScheduled = false;
495
+ const scheduleInject = () => {
496
+ if (injectScheduled) return;
497
+ injectScheduled = true;
498
+ const run = () => {
499
+ injectScheduled = false;
500
+ try {
501
+ injectRowButtons();
502
+ } catch (error) {
503
+ console.error("[dsh-mcp-manager] row button injection error", error);
504
+ }
505
+ };
506
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
507
+ else setTimeout(run, 50);
508
+ };
509
+ const observer = typeof MutationObserver === "function" ? new MutationObserver(scheduleInject) : null;
510
+ const observeBody = () => {
511
+ if (observer !== null && document.body !== null) {
512
+ observer.observe(document.body, { childList: true, subtree: true });
513
+ }
514
+ };
515
+ if (document.body !== null) observeBody();
516
+ else document.addEventListener("DOMContentLoaded", observeBody, { once: true });
517
+ // Safety net: React may reconcile a row without a mutation we observe
518
+ // ordering-wise, so re-check on a slow interval as well.
519
+ const sweepTimer = setInterval(scheduleInject, 1500);
520
+ const offWorkspaces = ctx.workspaces?.list?.subscribe?.(scheduleInject) ?? null;
521
+ scheduleInject();
522
+ ctx.effect(() => () => {
523
+ if (observer !== null) observer.disconnect();
524
+ document.removeEventListener("DOMContentLoaded", observeBody);
525
+ clearInterval(sweepTimer);
526
+ if (typeof offWorkspaces === "function") offWorkspaces();
527
+ for (const node of document.querySelectorAll("[" + BUTTON_ATTR + "]")) node.remove();
528
+ }, "dsh-mcp-manager: workspace-row button");
529
+ }
530
+
531
+ exports.apply = apply;
532
+ exports.inject = inject;
533
+ return module.exports;
534
+ },
535
+ });
@@ -0,0 +1,5 @@
1
+ # dsh-mcp-manager bundle patch: mount the MCP manager plugin (host half).
2
+ # The name must be the full package name, like every shipped bundle patch.
3
+ - insert:
4
+ - id: dsh-mcp-manager
5
+ name: "@gyp8735abc/dsh-mcp-manager"
package/icon.svg ADDED
@@ -0,0 +1,11 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <title>dsh-mcp-manager</title>
3
+ <rect x="0.75" y="0.75" width="34.5" height="34.5" rx="9.25" fill="#4D6BFE" fill-opacity="0.10"/>
4
+ <rect x="0.75" y="0.75" width="34.5" height="34.5" rx="9.25" stroke="#4D6BFE" stroke-opacity="0.30" stroke-width="1.5"/>
5
+ <path d="M25.5 3H11.5A8.5 8.5 0 0 0 3 11.5V24.5A8.5 8.5 0 0 0 11.5 33H25.5"
6
+ stroke="#4D6BFE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
7
+ <rect x="9.5" y="13" width="11" height="10" rx="2.5" fill="#4D6BFE"/>
8
+ <rect x="20.5" y="15.25" width="3.25" height="1.9" rx="0.95" fill="#4D6BFE"/>
9
+ <rect x="20.5" y="18.85" width="3.25" height="1.9" rx="0.95" fill="#4D6BFE"/>
10
+ <path d="M23.75 18H32" stroke="#4D6BFE" stroke-width="2.25" stroke-linecap="round"/>
11
+ </svg>