/* global window, document, fetch */ // Runs inside the sandboxed, opaque-origin editor frame. Plain browser JS: it is inlined into the frame // HTML and allowed by CSP hash, so it must not import anything. (function main() { "use strict"; // The frame document is served by the forge itself; only its runtime origin is opaque. var forgeOrigin = new URL(window.location.href).origin; var parentOrigin = forgeOrigin; var apiBase = forgeOrigin; var token = null; var session = null; var files = []; var buffers = new Map(); var deleted = new Set(); var openTabs = []; var active = null; var collapsed = new Set(); function $(id) { return document.getElementById(id); } function el(tag, attrs, children) { var node = document.createElement(tag); Object.keys(attrs || {}).forEach(function (key) { if (key === "text") node.textContent = attrs[key]; else if (key === "style") node.style.cssText = attrs[key]; else if (key.slice(0, 2) === "on") node.addEventListener(key.slice(2), attrs[key]); else node.setAttribute(key, attrs[key]); }); (children || []).forEach(function (child) { if (child) node.appendChild(child); }); return node; } function status(text, isError) { var bar = $("status-msg"); bar.textContent = text; bar.className = isError ? "error" : ""; } function api(path, init) { var options = init || {}; var headers = { authorization: "Bearer " + token }; if (options.body) headers["content-type"] = "application/json"; return fetch(apiBase + "/api/editor/" + path, { method: options.method || "GET", headers: headers, body: options.body, credentials: "omit", cache: "no-store", }).then(function (response) { return response.json().then(function (body) { if (!response.ok) throw new Error(body.error || "Request failed (" + response.status + ")"); return body; }); }); } function changes() { var out = []; buffers.forEach(function (buffer, path) { if (buffer.kind === "text" && buffer.current !== buffer.original && !deleted.has(path)) out.push({ path: path, content: buffer.current, status: buffer.isNew ? "A" : "M" }); }); deleted.forEach(function (path) { if (!(buffers.get(path) || {}).isNew) out.push({ path: path, content: null, status: "D" }); }); return out.sort(function (left, right) { return left.path.localeCompare(right.path); }); } function allPaths() { var set = new Set(files.map((entry) => entry.path)); buffers.forEach(function (buffer, path) { if (buffer.isNew) set.add(path); }); deleted.forEach(function (path) { set.delete(path); }); return Array.from(set).sort(); } function buildTree(paths) { var root = { dirs: new Map(), files: [] }; paths.forEach(function (path) { var parts = path.split("/"); var node = root; for (var index = 0; index < parts.length - 1; index++) { var prefix = parts.slice(0, index + 1).join("/"); if (!node.dirs.has(parts[index])) node.dirs.set(parts[index], { path: prefix, dirs: new Map(), files: [] }); node = node.dirs.get(parts[index]); } node.files.push(path); }); return root; } function renderTreeNode(node, depth, container) { Array.from(node.dirs.keys()) .sort() .forEach(function (name) { var dir = node.dirs.get(name); var closed = collapsed.has(dir.path); container.appendChild( el("div", { class: "row dir", style: "padding-left:" + (depth * 12 + 8) + "px", text: (closed ? "▸ " : "▾ ") + name, onclick: function () { if (closed) collapsed.delete(dir.path); else collapsed.add(dir.path); renderExplorer(); }, }), ); if (!closed) renderTreeNode(dir, depth + 1, container); }); node.files.forEach(function (path) { var buffer = buffers.get(path); var dirty = buffer && buffer.current !== buffer.original; container.appendChild( el("div", { class: "row file" + (path === active ? " active" : "") + (dirty ? " dirty" : ""), style: "padding-left:" + (depth * 12 + 20) + "px", "data-path": path, title: path, text: path.split("/").pop(), onclick: function () { openFile(path); }, }), ); }); } function renderExplorer() { var container = $("tree"); container.replaceChildren(); renderTreeNode(buildTree(allPaths()), 0, container); } function renderChanges() { var list = changes(); $("changes-count").textContent = String(list.length); var container = $("changes"); container.replaceChildren(); list.forEach(function (change) { container.appendChild( el("div", { class: "row change", "data-path": change.path, title: change.path }, [ el("span", { class: "badge s" + change.status, text: change.status }), el("span", { text: change.path, onclick: function () { if (change.status !== "D") openFile(change.path); }, }), el("button", { class: "link", title: "Discard change", text: "↺", onclick: function () { discard(change.path); }, }), ]), ); }); $("commit").disabled = list.length === 0 || !session.writable; } function renderTabs() { var container = $("tabs"); container.replaceChildren(); openTabs.forEach(function (path) { var buffer = buffers.get(path); var dirty = buffer && buffer.current !== buffer.original; container.appendChild( el("div", { class: "tab" + (path === active ? " active" : ""), title: path }, [ el("span", { text: path.split("/").pop() + (dirty ? " ●" : ""), onclick: function () { openFile(path); }, }), el("button", { class: "link", text: "×", onclick: function () { closeTab(path); }, }), ]), ); }); } function renderEditor() { var area = $("editor"); var notice = $("notice"); var buffer = active ? buffers.get(active) : null; $("crumb").textContent = active || ""; if (!buffer) { area.hidden = true; $("gutter").hidden = true; notice.hidden = false; notice.textContent = active ? "Loading…" : "Select a file to start editing."; return; } if (buffer.kind !== "text") { area.hidden = true; $("gutter").hidden = true; notice.hidden = false; notice.textContent = buffer.kind === "lfs" ? "This file is stored in Git LFS. It's shown read-only so the pointer is never overwritten." : buffer.kind === "too-large" ? "This file is too large to edit in the browser." : "Binary or special files can't be edited in the browser."; return; } notice.hidden = true; area.hidden = false; $("gutter").hidden = false; if (area.value !== buffer.current) area.value = buffer.current; area.readOnly = !session.writable; renderGutter(); } function renderGutter() { var lines = $("editor").value.split("\n").length; var out = []; for (var index = 1; index <= lines; index++) out.push(index); $("gutter").textContent = out.join("\n"); $("gutter").scrollTop = $("editor").scrollTop; } function renderAll() { renderExplorer(); renderTabs(); renderEditor(); renderChanges(); } function openFile(path) { if (openTabs.indexOf(path) === -1) openTabs.push(path); active = path; if (buffers.has(path)) return renderAll(); renderAll(); api("blob?path=" + encodeURIComponent(path)) .then(function (body) { var content = body.content === null ? "" : body.content; var kind = body.kind === "symlink" ? "symlink" : body.kind; buffers.set(path, { kind: kind, original: content, current: content, isNew: false }); renderAll(); }) .catch(function (error) { status(error.message, true); }); } function closeTab(path) { openTabs = openTabs.filter(function (candidate) { return candidate !== path; }); if (active === path) active = openTabs[openTabs.length - 1] || null; renderAll(); } function discard(path) { var buffer = buffers.get(path); deleted.delete(path); if (buffer && buffer.isNew) { buffers.delete(path); closeTab(path); } else if (buffer) buffer.current = buffer.original; renderAll(); } function newFile() { var base = active && active.indexOf("/") !== -1 ? active.slice(0, active.lastIndexOf("/") + 1) : ""; var path = window.prompt("New file path", base); if (!path) return; path = path.replace(/^\/+/, ""); if (deleted.has(path)) { deleted.delete(path); return openFile(path); } if (allPaths().indexOf(path) !== -1) return openFile(path); buffers.set(path, { kind: "text", original: "", current: "", isNew: true }); openFile(path); } function deleteActive() { if (!active || !window.confirm("Delete " + active + "?")) return; deleted.add(active); closeTab(active); } function commit() { var list = changes(); var message = $("message").value.trim(); if (!message) return status("Enter a commit message", true); var newBranch = $("branch-toggle").checked ? $("branch-name").value.trim() : ""; $("commit").disabled = true; status("Committing…"); api("commit", { method: "POST", body: JSON.stringify({ message: message, expectedHeadSha: session.sha, newBranch: newBranch || undefined, changes: list.map(function (change) { return { path: change.path, content: change.content }; }), }), }) .then(function (result) { var summary = "Committed " + result.sha.slice(0, 7) + " to " + result.branch; if (result.pullUrl) summary += "; opened a pull request"; if (result.pullError) summary += " (no pull request: " + result.pullError + ")"; status(summary); window.parent.postMessage({ type: "gitfor-editor:committed", result: result }, parentOrigin); if (result.branch !== session.ref) { list.forEach(function (change) { var buffer = buffers.get(change.path); if (buffer && buffer.isNew) buffers.delete(change.path); else if (buffer) buffer.current = buffer.original; deleted.delete(change.path); }); openTabs = openTabs.filter(function (path) { return buffers.has(path); }); if (active && !buffers.has(active)) active = openTabs[openTabs.length - 1] || null; $("message").value = ""; $("branch-name").value = ""; } else { session.sha = result.sha; var removed = new Set(); list.forEach(function (change) { var buffer = buffers.get(change.path); if (change.content === null) { removed.add(change.path); deleted.delete(change.path); buffers.delete(change.path); } else if (buffer) { if (buffer.isNew) files.push({ path: change.path }); buffer.original = change.content; buffer.isNew = false; } }); files = files.filter(function (entry) { return !removed.has(entry.path); }); $("message").value = ""; } renderAll(); }) .catch(function (error) { status(error.message, true); renderChanges(); }); } function onEditorInput() { var buffer = buffers.get(active); if (!buffer) return; buffer.current = $("editor").value; renderGutter(); renderTabs(); renderChanges(); renderExplorer(); } function onEditorKey(event) { if (event.key !== "Tab") return; event.preventDefault(); var area = $("editor"); area.setRangeText(" ", area.selectionStart, area.selectionEnd, "end"); onEditorInput(); } function start(sessionToken) { token = sessionToken; Promise.all([api("session"), api("tree")]) .then(function (results) { session = results[0]; files = results[1].entries; $("title").textContent = session.owner + "/" + session.repo; $("ref").textContent = session.ref + " @ " + session.sha.slice(0, 7); if (!session.writable) status("Read-only: you can't commit to this ref"); else if (session.protected) { $("branch-toggle").checked = true; $("branch-toggle").disabled = true; $("branch-hint").textContent = session.ref + " is protected; commits go to a new branch with a pull request."; } if (results[1].truncated) status("Large repository: only the first files are listed"); renderAll(); }) .catch(function (error) { status(error.message, true); }); } window.addEventListener("message", function (event) { if (event.source !== window.parent || event.origin !== parentOrigin) return; var data = event.data || {}; if (data.type === "gitfor-editor:init" && typeof data.token === "string" && !token) start(data.token); }); $("editor").addEventListener("input", onEditorInput); $("editor").addEventListener("keydown", onEditorKey); $("editor").addEventListener("scroll", function () { $("gutter").scrollTop = $("editor").scrollTop; }); $("new-file").addEventListener("click", newFile); $("delete-file").addEventListener("click", deleteActive); $("commit").addEventListener("click", commit); $("message").addEventListener("keydown", function (event) { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) commit(); }); $("branch-toggle").addEventListener("change", function () { $("branch-name").hidden = !$("branch-toggle").checked || $("branch-toggle").disabled; }); window.parent.postMessage({ type: "gitfor-editor:ready" }, parentOrigin); })();