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 / gutenberg / write-with-ai / GitBrowse.js

GitBrowse.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/gutenberg/write-with-ai/GitBrowse.js

299 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // ===== write-with-ai/GitBrowse.js — "Browse repository" picker for From Git =====
2 // Lets the user pick a connected repository, then a Pull Request, an Issue, or a
3 // file (folder-by-folder browse). Whatever is picked resolves to a github.com URL
4 // that the parent modal feeds to the existing `from-git` generation path — this
5 // component never generates anything itself, it only builds the URL.
6
7 import { __ } from "@wordpress/i18n";
8 import { useState, useEffect, useRef } from "@wordpress/element";
9
10 import { DropdownSelect, FieldLabel, Banner, Spinner, IcCheck, IcChevDown } from "../ai-shared/kit";
11 import { callWriteWithAI } from "./api";
12
13 // Source-type segmented (PR | Issue | File) — mirrors the modal's SegControl look.
14 const TYPES = [
15 { value: "pull", label: __("Pull Request", "betterdocs") },
16 { value: "issue", label: __("Issue", "betterdocs") },
17 { value: "file", label: __("Repository file", "betterdocs") },
18 ];
19
20 const TypeSeg = ({ value, onChange, disabled }) => (
21 <div style={{ display: "flex", gap: 2, background: "var(--base-background, #fff)", border: "1px solid var(--text-color-200, #eaecf0)", padding: 4, borderRadius: 8, opacity: disabled ? 0.6 : 1 }}>
22 {TYPES.map((o) => {
23 const active = value === o.value;
24 return (
25 <button key={o.value} type="button" disabled={disabled} onClick={() => onChange(o.value)} style={{
26 flex: "1 1 0", minWidth: 0, padding: "7px 6px", borderRadius: 9, border: "none", cursor: disabled ? "default" : "pointer",
27 fontSize: 13, fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
28 background: active ? "var(--text-color-50, #f9fafb)" : "transparent",
29 color: active ? "var(--green)" : "var(--muted)",
30 transition: "background .12s, color .12s",
31 }}>
32 {o.label}
33 </button>
34 );
35 })}
36 </div>
37 );
38
39 // Tiny folder / file glyphs (inherit currentColor).
40 const IcFolder = ({ size = 16 }) => (
41 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
42 <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
43 </svg>
44 );
45 const IcFile = ({ size = 16 }) => (
46 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
47 <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" /><path d="M14 3v5h5" />
48 </svg>
49 );
50
51 const errText = (e) => (e && e.message) || __("Something went wrong. Please try again.", "betterdocs");
52
53 // Amber "Private" pill — mirrors Settings → Git Sync → Repository
54 // (.bd-rs-repo-tag--private) instead of a lock glyph. Hex fallbacks keep the
55 // colour correct inside the editor, where the --warning-* vars may be absent.
56 const PrivateTag = () => (
57 <span style={{
58 display: "inline-block", fontSize: 10, fontWeight: 600, lineHeight: 1,
59 padding: "3px 6px", borderRadius: 3, letterSpacing: 0.3, marginLeft: 8,
60 background: "var(--warning-50, #fffaeb)", color: "var(--warning-700, #b54708)",
61 border: "1px solid var(--warning-100, #fef0c7)",
62 }}>{__("Private", "betterdocs")}</span>
63 );
64
65 // react-select option renderer: repo name + a "Private" pill for private repos.
66 const formatRepoOption = (opt) => (
67 <span style={{ display: "inline-flex", alignItems: "center", minWidth: 0 }}>
68 <span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{opt.label}</span>
69 {opt.private && <PrivateTag />}
70 </span>
71 );
72
73 // A single directory-listing browser. Navigates folders in place; selecting a
74 // file reports its blob URL up via onPick.
75 const FileBrowser = ({ repo, onPick, disabled }) => {
76 const [ref, setRef] = useState("");
77 const [path, setPath] = useState("");
78 const [items, setItems] = useState([]);
79 const [selected, setSelected] = useState(""); // selected file path
80 const [loading, setLoading] = useState(false);
81 const [error, setError] = useState(null);
82 const reqId = useRef(0);
83
84 const load = (nextPath, keepRef) => {
85 const id = ++reqId.current;
86 setLoading(true);
87 setError(null);
88 callWriteWithAI("git-contents", { repo, path: nextPath, ref: keepRef || "" })
89 .then((data) => {
90 if (id !== reqId.current) return; // stale
91 setRef(data.ref || keepRef || "");
92 setPath(data.path != null ? data.path : nextPath);
93 setItems(Array.isArray(data.items) ? data.items : []);
94 })
95 .catch((e) => { if (id === reqId.current) { setError(e); setItems([]); } })
96 .finally(() => { if (id === reqId.current) setLoading(false); });
97 };
98
99 // (Re)load the root whenever the repo changes; reset any prior selection.
100 useEffect(() => {
101 setSelected("");
102 onPick("");
103 if (repo) load("", "");
104 else { setItems([]); setPath(""); setRef(""); }
105 // eslint-disable-next-line react-hooks/exhaustive-deps
106 }, [repo]);
107
108 const openDir = (p) => { setSelected(""); onPick(""); load(p, ref); };
109 const pickFile = (it) => {
110 setSelected(it.path);
111 onPick(`https://github.com/${repo}/blob/${ref}/${it.path}`);
112 };
113
114 // Breadcrumb from the current path.
115 const crumbs = path ? path.split("/") : [];
116 const goCrumb = (i) => openDir(crumbs.slice(0, i + 1).join("/"));
117
118 return (
119 <div>
120 {/* Breadcrumb */}
121 <div style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 4, marginBottom: 8, fontSize: 12.5, color: "var(--muted)", fontWeight: 600 }}>
122 <button type="button" onClick={() => openDir("")} style={crumbBtn}>{__("Root", "betterdocs")}</button>
123 {crumbs.map((c, i) => (
124 <span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
125 <span style={{ color: "var(--text-color-300, #dde2e4)" }}>/</span>
126 <button type="button" onClick={() => goCrumb(i)} style={crumbBtn}>{c}</button>
127 </span>
128 ))}
129 </div>
130
131 {error && <Banner tone="error" style={{ marginBottom: 10 }}>{errText(error)}</Banner>}
132
133 <div style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 6, maxHeight: 240, overflowY: "auto", position: "relative", minHeight: 90 }}>
134 {loading && (
135 <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 8, padding: "28px 0", color: "var(--muted)", fontSize: 13 }}>
136 <Spinner size={16} /> {__("Loading…", "betterdocs")}
137 </div>
138 )}
139 {!loading && !error && items.length === 0 && (
140 <div style={{ padding: "26px 10px", textAlign: "center", color: "var(--muted)", fontSize: 13 }}>{__("This folder is empty.", "betterdocs")}</div>
141 )}
142 {!loading && items.map((it) => {
143 const isDir = it.type === "dir";
144 const isSel = !isDir && selected === it.path;
145 return (
146 <button key={it.path} type="button" disabled={disabled}
147 onClick={() => (isDir ? openDir(it.path) : pickFile(it))}
148 style={{
149 display: "flex", alignItems: "center", gap: 10, width: "100%", textAlign: "left",
150 padding: "8px 10px", borderRadius: 8, border: "none", cursor: "pointer",
151 background: isSel ? "var(--text-color-50, #f9fafb)" : "transparent",
152 color: isSel ? "var(--green)" : "var(--ink)", fontSize: 13.5, fontWeight: isSel ? 700 : 500,
153 transition: "background .12s",
154 }}
155 onMouseEnter={(e) => { if (!isSel) e.currentTarget.style.background = "#f4f6f8"; }}
156 onMouseLeave={(e) => { if (!isSel) e.currentTarget.style.background = "transparent"; }}>
157 <span style={{ display: "flex", flexShrink: 0, color: isSel ? "var(--green)" : (isDir ? "var(--muted)" : "#b6bdc7") }}>
158 {isDir ? <IcFolder size={16} /> : <IcFile size={16} />}
159 </span>
160 <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.name}</span>
161 {isDir && <span style={{ display: "flex", color: "#c4cbd5", transform: "rotate(-90deg)" }}><IcChevDown size={15} /></span>}
162 {isSel && <span style={{ display: "flex", color: "var(--green)" }}><IcCheck size={16} sw={2.4} /></span>}
163 </button>
164 );
165 })}
166 </div>
167 </div>
168 );
169 };
170
171 const crumbBtn = {
172 border: "none", background: "transparent", cursor: "pointer", padding: "2px 4px",
173 borderRadius: 5, color: "var(--wp-blue, #2271b1)", fontSize: 12.5, fontWeight: 600,
174 };
175
176 // ── The browse picker ─────────────────────────────────────────────────────────
177 const GitBrowse = ({ onSelect, disabled, settingsUrl }) => {
178 const [repos, setRepos] = useState([]);
179 const [repoLoading, setRepoLoading] = useState(true);
180 const [repoError, setRepoError] = useState(null);
181 const [repo, setRepo] = useState("");
182
183 const [kind, setKind] = useState("pull"); // pull · issue · file
184 const [items, setItems] = useState([]);
185 const [itemsLoading, setItemsLoading] = useState(false);
186 const [itemsError, setItemsError] = useState(null);
187 const [item, setItem] = useState(""); // selected PR/issue URL
188 const itemsReq = useRef(0);
189
190 // Load the connected account's repositories once.
191 useEffect(() => {
192 setRepoLoading(true);
193 callWriteWithAI("git-repos", {})
194 .then((data) => setRepos(Array.isArray(data.repos) ? data.repos : []))
195 .catch((e) => setRepoError(e))
196 .finally(() => setRepoLoading(false));
197 }, []);
198
199 // Load PRs/Issues whenever the repo or kind (pull/issue) changes.
200 useEffect(() => {
201 setItem("");
202 onSelect("");
203 if (!repo || kind === "file") { setItems([]); return; }
204 const id = ++itemsReq.current;
205 setItemsLoading(true);
206 setItemsError(null);
207 callWriteWithAI("git-items", { repo, kind })
208 .then((data) => { if (id === itemsReq.current) setItems(Array.isArray(data.items) ? data.items : []); })
209 .catch((e) => { if (id === itemsReq.current) { setItemsError(e); setItems([]); } })
210 .finally(() => { if (id === itemsReq.current) setItemsLoading(false); });
211 // eslint-disable-next-line react-hooks/exhaustive-deps
212 }, [repo, kind]);
213
214 const repoOptions = repos.map((r) => ({ value: r.value, label: r.label, private: !!r.private }));
215 const pickItem = (url) => { setItem(url); onSelect(url); };
216
217 // Surface a clear reconnect prompt when the repo list can't load or comes back
218 // empty. The GitHub App user token is short-lived (~8h) and, once it lapses, the
219 // proxy returns no repositories — a silent empty dropdown looks broken, so show
220 // an explicit "session may have expired → reconnect" message instead.
221 if (repoError || (!repoLoading && repos.length === 0)) {
222 return (
223 <Banner tone={repoError ? "error" : "warn"}>
224 {repoError
225 ? errText(repoError)
226 : __("No repositories found. If GitHub shows connected but nothing loads here, your GitHub session has likely expired.", "betterdocs")}
227 {settingsUrl && (
228 <>
229 {" "}
230 <a href={settingsUrl} target="_blank" rel="noreferrer"
231 style={{ color: "var(--wp-blue)", fontWeight: 700, textDecoration: "none" }}>
232 {__("Reconnect in Settings → Git Integration", "betterdocs")}
233 </a>
234 </>
235 )}
236 </Banner>
237 );
238 }
239
240 return (
241 <div>
242 {/* Repository */}
243 <FieldLabel>{__("Repository", "betterdocs")}</FieldLabel>
244 <DropdownSelect
245 value={repo}
246 onChange={(v) => setRepo(v)}
247 options={repoOptions}
248 formatOptionLabel={formatRepoOption}
249 isSearchable
250 disabled={disabled || repoLoading}
251 placeholder={repoLoading ? __("Loading repositories…", "betterdocs") : __("Search a repository…", "betterdocs")}
252 style={{ width: "100%" }}
253 />
254
255 {/* Type */}
256 <div style={{ marginTop: 14 }}>
257 <FieldLabel>{__("What to document", "betterdocs")}</FieldLabel>
258 <TypeSeg value={kind} onChange={setKind} disabled={disabled || !repo} />
259 </div>
260
261 {/* Selection */}
262 <div style={{ marginTop: 14 }}>
263 {!repo && (
264 <div style={{ fontSize: 12.5, color: "var(--muted)", padding: "6px 2px" }}>
265 {__("Choose a repository to continue.", "betterdocs")}
266 </div>
267 )}
268
269 {repo && kind !== "file" && (
270 <>
271 <FieldLabel>{kind === "issue" ? __("Select an issue", "betterdocs") : __("Select a pull request", "betterdocs")}</FieldLabel>
272 {itemsError
273 ? <Banner tone="error">{errText(itemsError)}</Banner>
274 : <DropdownSelect
275 value={item}
276 onChange={pickItem}
277 options={items}
278 isSearchable
279 disabled={disabled || itemsLoading}
280 placeholder={
281 itemsLoading ? __("Loading…", "betterdocs")
282 : items.length === 0 ? (kind === "issue" ? __("No open issues found", "betterdocs") : __("No open pull requests found", "betterdocs"))
283 : (kind === "issue" ? __("Search an issue…", "betterdocs") : __("Search a pull request…", "betterdocs"))
284 }
285 style={{ width: "100%" }}
286 />}
287 </>
288 )}
289
290 {repo && kind === "file" && (
291 <FileBrowser repo={repo} onPick={onSelect} disabled={disabled} />
292 )}
293 </div>
294 </div>
295 );
296 };
297
298 export default GitBrowse;
299