| 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 |
|