| 1 | (function () { |
| 2 | "use strict"; |
| 3 | |
| 4 | var domainCatalog = []; |
| 5 | var formatCatalog = [ |
| 6 | { id: "h1", path: "/h1", help: "Heading 1", category: "Format", insert: "# " }, |
| 7 | { id: "h2", path: "/h2", help: "Heading 2", category: "Format", insert: "## " }, |
| 8 | { id: "bul", path: "/bul", help: "Bullet list", category: "Format", insert: "- " }, |
| 9 | { id: "bold", path: "/bold", help: "Bold selection", category: "Format", wrap: ["**", "**"] }, |
| 10 | { id: "code", path: "/code", help: "Inline code", category: "Format", wrap: ["`", "`"] }, |
| 11 | { id: "quote", path: "/quote", help: "Block quote", category: "Format", insert: "> " }, |
| 12 | ]; |
| 13 | |
| 14 | var tierOrder = { core: 0, extended: 1, zoo: 2 }; |
| 15 | var zooCollapsed = true; |
| 16 | |
| 17 | var modalOpen = false; |
| 18 | var selectedIndex = 0; |
| 19 | var filterText = ""; |
| 20 | var activeSurface = "command-bar"; |
| 21 | var activeComposer = null; |
| 22 | var activeContext = {}; |
| 23 | |
| 24 | function parseContext(raw) { |
| 25 | if (!raw) return {}; |
| 26 | try { |
| 27 | return JSON.parse(raw); |
| 28 | } catch (_) { |
| 29 | return {}; |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | function mergedContext() { |
| 34 | var body = document.body; |
| 35 | var base = {}; |
| 36 | var repo = body && body.getAttribute("data-forge-repo"); |
| 37 | if (repo) base.repo = repo; |
| 38 | return Object.assign(base, activeContext); |
| 39 | } |
| 40 | |
| 41 | function normalizeCategory(cmd) { |
| 42 | var tier = cmd.tier || "core"; |
| 43 | if (tier === "zoo") return "Extensions"; |
| 44 | return cmd.category || "Forge"; |
| 45 | } |
| 46 | |
| 47 | function sortDomainCatalog(items) { |
| 48 | items.sort(function (a, b) { |
| 49 | var ta = tierOrder[a.tier] ?? 1; |
| 50 | var tb = tierOrder[b.tier] ?? 1; |
| 51 | if (ta !== tb) return ta - tb; |
| 52 | return (a.path || "").localeCompare(b.path || ""); |
| 53 | }); |
| 54 | return items; |
| 55 | } |
| 56 | |
| 57 | async function loadDomainCatalog(surface) { |
| 58 | try { |
| 59 | var res = await fetch("/api/v1/capabilities?surface=" + encodeURIComponent(surface)); |
| 60 | if (!res.ok) return []; |
| 61 | var data = await res.json(); |
| 62 | var items = (data.commands || []).map(function (cmd) { |
| 63 | var tier = cmd.tier || "core"; |
| 64 | return { |
| 65 | kind: "domain", |
| 66 | path: cmd.path, |
| 67 | help: cmd.help || "", |
| 68 | category: normalizeCategory({ tier: tier, category: cmd.category }), |
| 69 | tier: tier, |
| 70 | }; |
| 71 | }); |
| 72 | return sortDomainCatalog(items); |
| 73 | } catch (_) { |
| 74 | return []; |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | function allItems() { |
| 79 | var includeFormat = activeSurface === "mr-comment" || activeSurface === "issue-comment"; |
| 80 | var formats = includeFormat |
| 81 | ? formatCatalog.map(function (f) { |
| 82 | return { kind: "format", path: f.path, help: f.help, category: f.category, format: f, tier: "core" }; |
| 83 | }) |
| 84 | : []; |
| 85 | var merged = formats.concat(domainCatalog); |
| 86 | var q = filterText.trim().toLowerCase(); |
| 87 | if (!q) return merged; |
| 88 | return merged.filter(function (item) { |
| 89 | var path = (item.path || "").toLowerCase(); |
| 90 | var help = (item.help || "").toLowerCase(); |
| 91 | var cat = (item.category || "").toLowerCase(); |
| 92 | return path.indexOf(q) >= 0 || help.indexOf(q) >= 0 || cat.indexOf(q) >= 0; |
| 93 | }); |
| 94 | } |
| 95 | |
| 96 | function isZooCategory(category) { |
| 97 | return category === "Extensions"; |
| 98 | } |
| 99 | |
| 100 | function renderList() { |
| 101 | var list = document.getElementById("forge-slash-list"); |
| 102 | if (!list) return; |
| 103 | var items = allItems(); |
| 104 | selectedIndex = Math.min(selectedIndex, Math.max(0, items.length - 1)); |
| 105 | list.innerHTML = ""; |
| 106 | |
| 107 | var lastCategory = null; |
| 108 | items.forEach(function (item, index) { |
| 109 | if (item.category && item.category !== lastCategory) { |
| 110 | lastCategory = item.category; |
| 111 | var heading = document.createElement("li"); |
| 112 | heading.className = "forge-slash-category"; |
| 113 | if (isZooCategory(item.category)) { |
| 114 | heading.className += " forge-slash-category-zoo" + (zooCollapsed ? " collapsed" : ""); |
| 115 | heading.textContent = item.category + (zooCollapsed ? " ▸" : " ▾"); |
| 116 | heading.addEventListener("mousedown", function (e) { |
| 117 | e.preventDefault(); |
| 118 | zooCollapsed = !zooCollapsed; |
| 119 | renderList(); |
| 120 | }); |
| 121 | } else { |
| 122 | heading.textContent = item.category; |
| 123 | } |
| 124 | list.appendChild(heading); |
| 125 | } |
| 126 | |
| 127 | if (isZooCategory(item.category) && zooCollapsed) return; |
| 128 | |
| 129 | var li = document.createElement("li"); |
| 130 | li.className = "forge-slash-item" + (item.kind === "format" ? " format" : "") + (index === selectedIndex ? " active" : ""); |
| 131 | li.setAttribute("role", "option"); |
| 132 | li.dataset.index = String(index); |
| 133 | li.innerHTML = |
| 134 | "<span class=\"forge-slash-path\">/" + escapeHtml((item.path || "").replace(/^\//, "")) + "</span>" + |
| 135 | (item.help ? "<span class=\"forge-slash-help\">" + escapeHtml(item.help) + "</span>" : ""); |
| 136 | li.addEventListener("mousedown", function (e) { |
| 137 | e.preventDefault(); |
| 138 | pickItem(item, ""); |
| 139 | }); |
| 140 | list.appendChild(li); |
| 141 | }); |
| 142 | |
| 143 | if (items.length === 0) { |
| 144 | var empty = document.createElement("li"); |
| 145 | empty.className = "forge-slash-empty"; |
| 146 | empty.textContent = "No matching commands"; |
| 147 | list.appendChild(empty); |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | function escapeHtml(value) { |
| 152 | return String(value) |
| 153 | .replace(/&/g, "&") |
| 154 | .replace(/</g, "<") |
| 155 | .replace(/>/g, ">") |
| 156 | .replace(/"/g, """); |
| 157 | } |
| 158 | |
| 159 | async function openModal(initial, surface, composer, context) { |
| 160 | var modal = document.getElementById("forge-slash-modal"); |
| 161 | var input = document.getElementById("forge-slash-input"); |
| 162 | if (!modal || !input) return; |
| 163 | activeSurface = surface || "command-bar"; |
| 164 | activeComposer = composer || null; |
| 165 | activeContext = context || {}; |
| 166 | zooCollapsed = true; |
| 167 | domainCatalog = await loadDomainCatalog(activeSurface); |
| 168 | modalOpen = true; |
| 169 | modal.hidden = false; |
| 170 | filterText = initial || ""; |
| 171 | input.value = filterText; |
| 172 | selectedIndex = 0; |
| 173 | renderList(); |
| 174 | input.focus(); |
| 175 | } |
| 176 | |
| 177 | function closeModal() { |
| 178 | var modal = document.getElementById("forge-slash-modal"); |
| 179 | if (!modal) return; |
| 180 | modalOpen = false; |
| 181 | modal.hidden = true; |
| 182 | filterText = ""; |
| 183 | selectedIndex = 0; |
| 184 | activeComposer = null; |
| 185 | activeContext = {}; |
| 186 | } |
| 187 | |
| 188 | function applyFormat(formatDef) { |
| 189 | var ta = activeComposer; |
| 190 | if (!ta || !formatDef) return; |
| 191 | var start = ta.selectionStart; |
| 192 | var end = ta.selectionEnd; |
| 193 | var text = ta.value; |
| 194 | if (formatDef.wrap) { |
| 195 | var selected = text.slice(start, end) || "text"; |
| 196 | var wrapped = formatDef.wrap[0] + selected + formatDef.wrap[1]; |
| 197 | ta.value = text.slice(0, start) + wrapped + text.slice(end); |
| 198 | ta.focus(); |
| 199 | ta.selectionStart = start + formatDef.wrap[0].length; |
| 200 | ta.selectionEnd = start + formatDef.wrap[0].length + selected.length; |
| 201 | return; |
| 202 | } |
| 203 | if (formatDef.insert) { |
| 204 | ta.value = text.slice(0, start) + formatDef.insert + text.slice(end); |
| 205 | ta.focus(); |
| 206 | ta.selectionStart = ta.selectionEnd = start + formatDef.insert.length; |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | async function runDomainCommand(path, argsTail) { |
| 211 | closeModal(); |
| 212 | var payload = { |
| 213 | path: path, |
| 214 | args: argsTail || "", |
| 215 | context: mergedContext(), |
| 216 | }; |
| 217 | try { |
| 218 | var res = await fetch("/api/v1/commands/execute", { |
| 219 | method: "POST", |
| 220 | headers: { |
| 221 | "Content-Type": "application/json", |
| 222 | Accept: "application/json", |
| 223 | "X-Forge-Command-Client": "slash", |
| 224 | }, |
| 225 | body: JSON.stringify(payload), |
| 226 | }); |
| 227 | var data = await res.json(); |
| 228 | if (data.kind === "redirect" && data.redirectUrl) { |
| 229 | window.location.href = data.redirectUrl; |
| 230 | return; |
| 231 | } |
| 232 | if (!res.ok || data.kind === "error") { |
| 233 | window.alert(data.error || "Command failed."); |
| 234 | } |
| 235 | } catch (err) { |
| 236 | window.alert(String(err)); |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | function pickItem(item, tail) { |
| 241 | if (!item) return; |
| 242 | if (item.kind === "format") { |
| 243 | applyFormat(item.format); |
| 244 | closeModal(); |
| 245 | if (activeComposer) activeComposer.focus(); |
| 246 | return; |
| 247 | } |
| 248 | runDomainCommand(item.path, tail); |
| 249 | } |
| 250 | |
| 251 | function onInputKeyDown(event) { |
| 252 | var items = allItems().filter(function (item) { |
| 253 | return !(isZooCategory(item.category) && zooCollapsed); |
| 254 | }); |
| 255 | if (event.key === "Escape") { |
| 256 | event.preventDefault(); |
| 257 | closeModal(); |
| 258 | return; |
| 259 | } |
| 260 | if (event.key === "ArrowDown") { |
| 261 | event.preventDefault(); |
| 262 | selectedIndex = Math.min(selectedIndex + 1, Math.max(0, items.length - 1)); |
| 263 | renderList(); |
| 264 | return; |
| 265 | } |
| 266 | if (event.key === "ArrowUp") { |
| 267 | event.preventDefault(); |
| 268 | selectedIndex = Math.max(selectedIndex - 1, 0); |
| 269 | renderList(); |
| 270 | return; |
| 271 | } |
| 272 | if (event.key === "Enter") { |
| 273 | event.preventDefault(); |
| 274 | var text = event.target.value || ""; |
| 275 | var space = text.indexOf(" "); |
| 276 | var tail = space >= 0 ? text.slice(space + 1).trim() : ""; |
| 277 | var match = items[selectedIndex]; |
| 278 | if (match) { |
| 279 | pickItem(match, tail); |
| 280 | return; |
| 281 | } |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | function bindComposers() { |
| 286 | document.querySelectorAll("textarea.forge-composer").forEach(function (ta) { |
| 287 | ta.addEventListener("keydown", function (e) { |
| 288 | if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey) return; |
| 289 | var pos = ta.selectionStart; |
| 290 | var before = ta.value.slice(0, pos); |
| 291 | if (pos > 0 && before.length > 0 && !/\s$/.test(before)) return; |
| 292 | e.preventDefault(); |
| 293 | var ctx = parseContext(ta.getAttribute("data-forge-context")); |
| 294 | openModal("", ta.getAttribute("data-forge-surface") || "mr-comment", ta, ctx); |
| 295 | }); |
| 296 | }); |
| 297 | } |
| 298 | |
| 299 | function bindUi() { |
| 300 | var trigger = document.getElementById("forge-command-bar-trigger"); |
| 301 | var input = document.getElementById("forge-slash-input"); |
| 302 | var modal = document.getElementById("forge-slash-modal"); |
| 303 | if (trigger) { |
| 304 | trigger.addEventListener("click", function () { |
| 305 | openModal("", "command-bar", null, {}); |
| 306 | }); |
| 307 | } |
| 308 | if (input) { |
| 309 | input.addEventListener("input", function (e) { |
| 310 | filterText = e.target.value || ""; |
| 311 | selectedIndex = 0; |
| 312 | renderList(); |
| 313 | }); |
| 314 | input.addEventListener("keydown", onInputKeyDown); |
| 315 | } |
| 316 | if (modal) { |
| 317 | modal.addEventListener("click", function (e) { |
| 318 | if (e.target === modal) closeModal(); |
| 319 | }); |
| 320 | } |
| 321 | document.addEventListener("keydown", function (e) { |
| 322 | if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { |
| 323 | e.preventDefault(); |
| 324 | if (modalOpen) closeModal(); |
| 325 | else openModal("", "command-bar", null, {}); |
| 326 | } |
| 327 | }); |
| 328 | bindComposers(); |
| 329 | } |
| 330 | |
| 331 | if (document.readyState === "loading") { |
| 332 | document.addEventListener("DOMContentLoaded", bindUi); |
| 333 | } else { |
| 334 | bindUi(); |
| 335 | } |
| 336 | })(); |
| 337 | |