PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.5
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.5
4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 All 203 releases
betterdocs / react-src / admin / mcp / components / EditorsTab.js

EditorsTab.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/admin/mcp/components/EditorsTab.js

426 lines 17.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useRef, useState } from "react";
2 import { __, sprintf } from "@wordpress/i18n";
3
4 import CopyField from "./CopyField";
5 import { IconClaude, IconCode, IconCursor, IconExternal, IconInfo, IconPlug, IconSpark } from "./Icons";
6
7 /**
8 * The "Editors & CLI" tab: a rail of five clients on the left, one detail panel
9 * on the right.
10 *
11 * It replaces an earlier two-column grid of five cards. Those cards had
12 * wildly different natural heights — a deep-link button next to a twelve-line
13 * prompt — so the grid stretched the short ones to the tallest in their row and
14 * left the fifth alone in a half-empty one (ADR-057). A picker shows one client
15 * at a time, which is all anyone needs: nobody sets up five editors at once.
16 *
17 * The OAuth tab is untouched and still lives in `ConnectionConfig.js`; its two
18 * cards are the same height and the grid suits them.
19 */
20
21 // The green this plugin's design tokens call `--base-color-700`. Written out
22 // because the accents are inline custom properties, not stylesheet values.
23 const BD_GREEN = "#00b884";
24
25 // UTF-8 safe base64: btoa() throws on multi-byte characters, and a site title
26 // or token is not guaranteed to be ASCII.
27 const toBase64 = (str) => {
28 try {
29 return window.btoa(
30 encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))),
31 );
32 } catch (e) {
33 return "";
34 }
35 };
36
37 /**
38 * The accent tile. The brand colour arrives as a custom property so the
39 * stylesheet — not this component — decides what happens to it in dark mode,
40 * where Cursor's near-black would otherwise vanish (ADR-054).
41 *
42 * `solid` marks a tile whose glyph is a vendor's own logo file rather than one
43 * of ours: it draws a plain white chip with a hairline border, so the logo keeps
44 * its own colour (ADR-066). Only the clients on this rail that carry somebody
45 * else's logo use it — VS Code, the JSON block and the prompt wear our own
46 * glyphs and keep the tint.
47 */
48 const Tile = ({ Icon, accent, accentDark, accentFgDark, solid, size = 36 }) => (
49 <span
50 className={`bd-mcp-tile bd-mcp-tile--${size}${solid ? " is-solid" : ""}`}
51 style={{
52 "--bd-accent": accent,
53 "--bd-accent-dark": accentDark || accent,
54 ...(accentFgDark ? { "--bd-accent-fg-dark": accentFgDark } : {}),
55 "--bd-accent-tint": `${accent}24`,
56 }}
57 >
58 <Icon size={size === 28 ? 15 : 18} />
59 </span>
60 );
61
62 const Steps = ({ steps }) => (
63 <ol className="bd-mcp-steps">
64 {steps.map((step, i) => (
65 <li key={step} className="bd-mcp-steps__item">
66 <span className="bd-mcp-steps__num">{i + 1}</span>
67 <span>{step}</span>
68 </li>
69 ))}
70 </ol>
71 );
72
73 /**
74 * One client's payload, in whichever of the two shapes it has: a copyable
75 * value, or a deep link with a line saying where it goes.
76 */
77 const Payload = ({ payload }) =>
78 payload.href ? (
79 <div className="bd-mcp-detail__pay">
80 <a className="bda-btn primary bd-mcp-detail__cta" href={payload.href}>
81 {payload.cta}
82 <IconExternal size={14} />
83 </a>
84 {payload.hint && <p className="bd-mcp-detail__hint">{payload.hint}</p>}
85 </div>
86 ) : (
87 <div className="bd-mcp-detail__pay">
88 <CopyField
89 label={payload.label}
90 value={payload.value}
91 multiline={!!payload.multiline}
92 help={payload.help}
93 />
94 </div>
95 );
96
97 export default function EditorsTab({ data }) {
98 const railRef = useRef(null);
99 const [selected, setSelected] = useState("claude-code");
100 // Per client, and deliberately not remembered: switching client is the
101 // moment to re-offer the safe path, not to carry a token choice across.
102 const [mode, setMode] = useState("browser");
103
104 const pick = (id) => {
105 setSelected(id);
106 setMode("browser");
107 };
108
109 const endpoint = data.mcp_endpoint || "";
110 const endpointRest = data.mcp_endpoint_rest || "";
111 const token = data.connection_token || "";
112
113 // Every browser-mode payload carries no credential: the endpoint answers an
114 // unauthenticated call with `401` + `WWW-Authenticate: Bearer
115 // resource_metadata=…`, which is the challenge that starts the OAuth flow,
116 // and this server accepts dynamic client registration with loopback
117 // redirect URIs (ADR-054). The token variants are built beside them for the
118 // machine that has no browser to open.
119 const oauthEntry = { url: endpoint };
120 const tokenEntry = { url: endpoint, headers: { Authorization: `Bearer ${token}` } };
121
122 const cursorLink = (entry) =>
123 `cursor://anysphere.cursor-deeplink/mcp/install?name=betterdocs&config=${encodeURIComponent(toBase64(JSON.stringify(entry)))}`;
124 const vscodeLink = (entry) =>
125 `vscode:mcp/install?${encodeURIComponent(JSON.stringify({ name: "betterdocs", type: "http", ...entry }))}`;
126
127 const mcpServersBlock = (entry) => JSON.stringify({ mcpServers: { betterdocs: entry } }, null, 2);
128
129 // Built here rather than server-side: `MCPPairing::config_snippets()` exists
130 // to carry the token, and a string with no secret in it needs no round trip.
131 const claudeCodeOauth = `claude mcp add --transport http betterdocs ${endpoint}`;
132
133 const jsonHelp = endpointRest
134 ? sprintf(
135 /* translators: %s: the REST fallback endpoint URL. */
136 __(
137 "Paste into any client that takes a JSON MCP config. If pretty permalinks are off, use %s as the url.",
138 "betterdocs",
139 ),
140 endpointRest,
141 )
142 : __("Paste into any client that takes a JSON MCP config.", "betterdocs");
143
144 // Steps shared between the two modes, so the pair can only differ where it
145 // is meant to: the browser flow has an approval step the token flow does not.
146 const RUN = __("Copy the command above and run it in your terminal.", "betterdocs");
147 const STATUS = __("Ask Claude to call bd-get-status to confirm the connection.", "betterdocs");
148 const JSON_COPY = __("Copy the block above into the client's MCP configuration file.", "betterdocs");
149 const JSON_RESTART = __("Restart the client so it reads the new server.", "betterdocs");
150 const ASK_COPY = __("Copy the prompt above.", "betterdocs");
151 const ASK_PASTE = __("Paste it into an assistant that can edit its own MCP configuration.", "betterdocs");
152
153 const clients = [
154 {
155 id: "claude-code",
156 name: "Claude Code",
157 badge: __("CLI", "betterdocs"),
158 // Claude Code is Anthropic's, so it wears Anthropic's mark on
159 // Anthropic's tile — the generic sparkle at Claude's terracotta was
160 // the "tinted glyph where the app icon belongs" this unit removes.
161 Icon: IconClaude,
162 accent: "#d97757",
163 solid: true,
164 desc: __("Add the server from your terminal, approve once in the browser.", "betterdocs"),
165 browser: {
166 label: __("Command", "betterdocs"),
167 value: claudeCodeOauth,
168 steps: [
169 RUN,
170 __(
171 "Run /mcp in Claude Code and choose BetterDocs — your browser opens this site's approval screen; sign in and approve.",
172 "betterdocs",
173 ),
174 STATUS,
175 ],
176 },
177 token: {
178 label: __("Command", "betterdocs"),
179 value: data.config?.cli || "",
180 multiline: true,
181 steps: [RUN, STATUS],
182 },
183 },
184 {
185 id: "cursor",
186 name: "Cursor",
187 badge: __("Editor", "betterdocs"),
188 Icon: IconCursor,
189 accent: "#0f172a",
190 // Near-black on a dark card is invisible, so the solid tile inverts
191 // there: light square, near-black arrow.
192 accentDark: "#cbd5e1",
193 accentFgDark: "#0f172a",
194 solid: true,
195 desc: __("One click opens Cursor with BetterDocs prefilled.", "betterdocs"),
196 browser: {
197 href: cursorLink(oauthEntry),
198 cta: __("Add to Cursor", "betterdocs"),
199 hint: __("Opens cursor://… with the server URL.", "betterdocs"),
200 steps: [
201 __('Click "Add to Cursor". It opens Cursor with BetterDocs prefilled.', "betterdocs"),
202 __("Confirm the install when Cursor asks.", "betterdocs"),
203 __("Approve the connection in the browser Cursor opens for this site's sign-in.", "betterdocs"),
204 ],
205 },
206 token: {
207 href: cursorLink(tokenEntry),
208 cta: __("Add to Cursor", "betterdocs"),
209 hint: __("Opens cursor://… with the server URL and the token.", "betterdocs"),
210 steps: [
211 __('Click "Add to Cursor". It opens Cursor with BetterDocs prefilled.', "betterdocs"),
212 __("Confirm the install when Cursor asks.", "betterdocs"),
213 ],
214 },
215 },
216 {
217 id: "vscode",
218 name: "VS Code",
219 badge: __("Editor", "betterdocs"),
220 Icon: IconCode,
221 accent: "#0098ff",
222 accentDark: "#4db8ff",
223 desc: __("One click opens VS Code with BetterDocs prefilled.", "betterdocs"),
224 browser: {
225 href: vscodeLink(oauthEntry),
226 cta: __("Add to VS Code", "betterdocs"),
227 hint: __("Opens vscode:mcp/install… with the server URL.", "betterdocs"),
228 steps: [
229 __('Click "Add to VS Code". It opens VS Code with BetterDocs prefilled.', "betterdocs"),
230 __("Confirm the install when VS Code asks.", "betterdocs"),
231 __("Approve the connection in the browser VS Code opens for this site's sign-in.", "betterdocs"),
232 ],
233 },
234 token: {
235 href: vscodeLink(tokenEntry),
236 cta: __("Add to VS Code", "betterdocs"),
237 hint: __("Opens vscode:mcp/install… with the server URL and the token.", "betterdocs"),
238 steps: [
239 __('Click "Add to VS Code". It opens VS Code with BetterDocs prefilled.', "betterdocs"),
240 __("Confirm the install when VS Code asks.", "betterdocs"),
241 ],
242 },
243 },
244 {
245 id: "json",
246 name: __("Claude Desktop & JSON clients", "betterdocs"),
247 badge: __("JSON", "betterdocs"),
248 badgeTone: "green",
249 Icon: IconPlug,
250 accent: BD_GREEN,
251 desc: __("Paste a block into any client that takes a JSON MCP config.", "betterdocs"),
252 browser: {
253 label: __("mcpServers block", "betterdocs"),
254 value: mcpServersBlock(oauthEntry),
255 multiline: true,
256 help: jsonHelp,
257 steps: [
258 JSON_COPY,
259 JSON_RESTART,
260 __("Approve the connection in the browser it opens for this site's sign-in.", "betterdocs"),
261 ],
262 },
263 token: {
264 label: __("mcpServers block", "betterdocs"),
265 value: data.config?.json || "",
266 multiline: true,
267 help: jsonHelp,
268 steps: [JSON_COPY, JSON_RESTART],
269 },
270 },
271 {
272 id: "prompt",
273 name: __("Ask your AI to set it up", "betterdocs"),
274 badge: __("Prompt", "betterdocs"),
275 badgeTone: "green",
276 Icon: IconSpark,
277 accent: BD_GREEN,
278 desc: __("Paste a prompt and the assistant adds the connection itself.", "betterdocs"),
279 browser: {
280 label: __("Prompt", "betterdocs"),
281 value: data.ai_prompt || "",
282 multiline: true,
283 help: __(
284 "It carries no credential — the assistant is sent here to sign in.",
285 "betterdocs",
286 ),
287 steps: [
288 ASK_COPY,
289 ASK_PASTE,
290 __("Approve the connection in the browser it opens, then ask it to call bd-get-status.", "betterdocs"),
291 ],
292 },
293 token: {
294 label: __("Prompt", "betterdocs"),
295 value: data.ai_prompt_token || "",
296 multiline: true,
297 steps: [ASK_COPY, ASK_PASTE, STATUS],
298 },
299 },
300 ];
301
302 const current = clients.find((c) => c.id === selected) || clients[0];
303 const payload = current[mode];
304
305 // Roving focus over the rail, the way a `tablist` is meant to behave: the
306 // selected item is the only tab stop, and the arrows move the selection.
307 // The handler sits on the tabs rather than the list, because the tabs are
308 // what hold focus — a `tablist` that is itself focusable would put a second,
309 // useless stop in the page's tab order.
310 const onRailKeyDown = (event) => {
311 const index = clients.findIndex((c) => c.id === selected);
312 let next = null;
313
314 if (event.key === "ArrowDown" || event.key === "ArrowRight") {
315 next = (index + 1) % clients.length;
316 } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
317 next = (index - 1 + clients.length) % clients.length;
318 } else if (event.key === "Home") {
319 next = 0;
320 } else if (event.key === "End") {
321 next = clients.length - 1;
322 }
323
324 if (next === null) return;
325
326 event.preventDefault();
327 pick(clients[next].id);
328 const button = railRef.current?.querySelector(`[data-client="${clients[next].id}"]`);
329 if (button) button.focus();
330 };
331
332 return (
333 <div className="bd-mcp-split">
334 <div
335 ref={railRef}
336 className="bd-mcp-rail"
337 role="tablist"
338 aria-orientation="vertical"
339 aria-label={__("Pick your tool", "betterdocs")}
340 >
341 <span className="bd-mcp-rail__label">{__("Pick your tool", "betterdocs")}</span>
342 {clients.map((c) => (
343 <button
344 key={c.id}
345 type="button"
346 role="tab"
347 data-client={c.id}
348 aria-selected={selected === c.id}
349 aria-controls="bd-mcp-detail"
350 tabIndex={selected === c.id ? 0 : -1}
351 className={`bd-mcp-rail__item${selected === c.id ? " is-selected" : ""}`}
352 onClick={() => pick(c.id)}
353 onKeyDown={onRailKeyDown}
354 >
355 <Tile
356 Icon={c.Icon}
357 accent={c.accent}
358 accentDark={c.accentDark}
359 accentFgDark={c.accentFgDark}
360 solid={c.solid}
361 size={28}
362 />
363 <span className="bd-mcp-rail__name">{c.name}</span>
364 <span className={`bda-badge ${c.badgeTone || "slate"}`}>{c.badge}</span>
365 </button>
366 ))}
367 </div>
368
369 <div className="bd-mcp-detail" id="bd-mcp-detail" role="tabpanel" tabIndex={-1}>
370 <div className="bd-mcp-detail__head">
371 <Tile
372 Icon={current.Icon}
373 accent={current.accent}
374 accentDark={current.accentDark}
375 accentFgDark={current.accentFgDark}
376 solid={current.solid}
377 size={36}
378 />
379 <div className="bd-mcp-detail__headtext">
380 <span className="bd-mcp-detail__name">
381 {current.name}
382 <span className={`bda-badge ${current.badgeTone || "slate"}`}>{current.badge}</span>
383 </span>
384 <span className="bd-mcp-detail__desc">{current.desc}</span>
385 </div>
386 </div>
387
388 <div className="bd-mcp-seg" role="group" aria-label={__("How this client signs in", "betterdocs")}>
389 <button
390 type="button"
391 className={`bd-mcp-seg__btn${mode === "browser" ? " is-active" : ""}`}
392 aria-pressed={mode === "browser"}
393 onClick={() => setMode("browser")}
394 >
395 {__("Sign in in your browser", "betterdocs")}
396 <small>{__("· Recommended", "betterdocs")}</small>
397 </button>
398 <button
399 type="button"
400 className={`bd-mcp-seg__btn${mode === "token" ? " is-active" : ""}`}
401 aria-pressed={mode === "token"}
402 onClick={() => setMode("token")}
403 >
404 {__("Use the connection token", "betterdocs")}
405 </button>
406 </div>
407
408 {mode === "token" && (
409 <p className="bd-mcp-warn">
410 <IconInfo size={14} />
411 {__(
412 "This carries your site's connection token. Only for a machine with no browser.",
413 "betterdocs",
414 )}
415 </p>
416 )}
417
418 <Payload payload={payload} />
419
420 <span className="bd-mcp-detail__label">{__("How to", "betterdocs")}</span>
421 <Steps steps={payload.steps} />
422 </div>
423 </div>
424 );
425 }
426