Fork of patx/gitman.
nova/gitman
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{title or "GitMan"}}</title>
<link rel="icon" type="image/svg+xml" sizes="32x32" href="/static/git.svg">
<link rel="icon" type="image/svg+xml" sizes="16x16" href="/static/git.svg">
<link rel="apple-touch-icon" sizes="180x180" href="/static/git.svg">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">
<link rel="stylesheet" href="/static/styles.css?v=5">
</head>
<body>
<header class="site-header">
<a class="brand" href="/">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-git" viewBox="0 0 16 16">
<path d="M15.698 7.287 8.712.302a1.03 1.03 0 0 0-1.457 0l-1.45 1.45 1.84 1.84a1.223 1.223 0 0 1 1.55 1.56l1.773 1.774a1.224 1.224 0 0 1 1.267 2.025 1.226 1.226 0 0 1-2.002-1.334L8.58 5.963v4.353a1.226 1.226 0 1 1-1.008-.036V5.887a1.226 1.226 0 0 1-.666-1.608L5.093 2.465l-4.79 4.79a1.03 1.03 0 0 0 0 1.457l6.986 6.986a1.03 1.03 0 0 0 1.457 0l6.953-6.953a1.03 1.03 0 0 0 0-1.457"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person-arms-up" viewBox="0 0 16 16">
<path d="M8 3a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"/>
<path d="m5.93 6.704-.846 8.451a.768.768 0 0 0 1.523.203l.81-4.865a.59.59 0 0 1 1.165 0l.81 4.865a.768.768 0 0 0 1.523-.203l-.845-8.451A1.5 1.5 0 0 1 10.5 5.5L13 2.284a.796.796 0 0 0-1.239-.998L9.634 3.84a.7.7 0 0 1-.33.235c-.23.074-.665.176-1.304.176-.64 0-1.074-.102-1.305-.176a.7.7 0 0 1-.329-.235L4.239 1.286a.796.796 0 0 0-1.24.998l2.5 3.216c.317.316.475.758.43 1.204Z"/>
</svg>
</a>
<nav class="nav">
% if user:
<a class="nav-icons" href="/{{user['username']}}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-person" viewBox="0 0 16 16">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/>
</svg>
</a>
<a class="nav-icons" href="/new">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-plus-square" viewBox="0 0 16 16">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
</svg>
</a>
% else:
<a href="/login">Log in</a>
<a class="button small" href="/signup">Sign up</a>
% end
</nav>
</header>
<main class="page">
% if error:
<div class="alert">{{error}}</div>
% end
% if notice:
<div class="notice">{{notice}}</div>
% end
{{!base}}
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>
(() => {
const addLineNumbers = (code, rawText) => {
const viewer = code.closest("[data-code-viewer]");
const pre = code.closest("pre.code");
if (!viewer || !pre || viewer.querySelector(".line-numbers")) return;
const text = rawText ?? code.textContent ?? "";
const lineCount = Math.max(1, text.split("\n").length - (text.endsWith("\n") ? 1 : 0));
const numbers = [];
const lineNumbers = document.createElement("pre");
lineNumbers.className = "line-numbers";
lineNumbers.setAttribute("aria-hidden", "true");
for (let line = 1; line <= lineCount; line += 1) {
numbers.push(line);
}
lineNumbers.textContent = numbers.join("\n");
viewer.insertBefore(lineNumbers, pre);
};
const blocks = document.querySelectorAll("pre code");
if (window.hljs) {
if (hljs.addPlugin) {
hljs.addPlugin({
"after:highlightElement": ({ el, text }) => addLineNumbers(el, text),
});
}
blocks.forEach((block) => {
hljs.highlightElement(block);
addLineNumbers(block);
});
return;
}
document.querySelectorAll("[data-code-viewer] pre.code code").forEach((block) => addLineNumbers(block));
})();
</script>
<script>
(() => {
const buttons = document.querySelectorAll("[data-copy-raw-url]");
if (!buttons.length) return;
const copyText = async (text) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.append(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
};
buttons.forEach((button) => {
button.addEventListener("click", async () => {
const rawUrl = button.dataset.copyRawUrl;
if (!rawUrl || button.disabled) return;
const originalLabel = button.getAttribute("aria-label") || "Copy file contents";
button.disabled = true;
button.setAttribute("aria-label", "Copying file contents");
try {
const response = await fetch(rawUrl, { headers: { Accept: "text/plain" } });
if (!response.ok) throw new Error("Unable to fetch file contents.");
await copyText(await response.text());
button.classList.add("is-copied");
button.setAttribute("aria-label", "Copied file contents");
window.setTimeout(() => {
button.classList.remove("is-copied");
button.setAttribute("aria-label", originalLabel);
}, 1500);
} catch (error) {
button.classList.add("copy-error");
button.setAttribute("aria-label", "Unable to copy file contents");
window.setTimeout(() => {
button.classList.remove("copy-error");
button.setAttribute("aria-label", originalLabel);
}, 1500);
} finally {
button.disabled = false;
}
});
});
})();
</script>
<script>
(() => {
const pickers = document.querySelectorAll("[data-ref-picker]");
if (!pickers.length) return;
const clearSearchResults = (picker) => {
picker.querySelectorAll("[data-ref-picker-search-result]").forEach((option) => option.remove());
};
const showInitialOptions = (picker) => {
const options = picker.querySelectorAll("[data-ref-picker-option]");
const empty = picker.querySelector("[data-ref-picker-empty]");
let visibleCount = 0;
clearSearchResults(picker);
options.forEach((option) => {
const isVisible = option.dataset.refInitial === "true";
option.hidden = !isVisible;
option.style.display = isVisible ? "" : "none";
if (isVisible) visibleCount += 1;
});
if (empty) empty.hidden = visibleCount > 0;
};
const buildRefUrl = (result) => {
const url = new URL(window.location.href);
["ref", "ref_type", "ref_value", "page"].forEach((key) => url.searchParams.delete(key));
const type = result.type || "tip";
url.searchParams.set("ref_type", type);
if (type !== "tip") url.searchParams.set("ref", result.name || "");
return `${url.pathname}${url.search}${url.hash}`;
};
const renderSearchResults = (picker, results, append = false) => {
const empty = picker.querySelector("[data-ref-picker-empty]");
const options = picker.querySelectorAll("[data-ref-picker-option]");
if (!append) {
clearSearchResults(picker);
options.forEach((option) => {
option.hidden = true;
option.style.display = "none";
});
}
results.forEach((result) => {
const option = document.createElement("a");
const label = document.createElement("span");
option.className = "ref-picker-option";
option.href = buildRefUrl(result);
option.setAttribute("role", "menuitem");
option.dataset.refPickerSearchResult = "true";
label.textContent = result.label || result.name || "";
option.append(label);
empty.parentElement.insertBefore(option, empty);
});
if (empty) {
empty.hidden = Boolean(picker.querySelector("[data-ref-picker-search-result]"));
}
};
const searchRefs = (picker, append = false) => {
const search = picker.querySelector("[data-ref-picker-search]");
const query = search ? search.value.trim() : "";
const state = picker._refPickerSearchState || {
token: 0,
query: "",
page: 0,
hasNext: false,
loading: false,
};
if (!query) {
picker._refPickerSearchState = { ...state, query: "", page: 0, hasNext: false, loading: false };
showInitialOptions(picker);
return;
}
if (append) {
if (state.query !== query || state.loading || !state.hasNext) return;
}
const token = append ? state.token : state.token + 1;
const page = append ? state.page + 1 : 1;
picker._refPickerSearchState = { ...state, token, query, loading: true };
const empty = picker.querySelector("[data-ref-picker-empty]");
if (!append) {
picker.querySelectorAll("[data-ref-picker-option]").forEach((option) => {
option.hidden = true;
option.style.display = "none";
});
clearSearchResults(picker);
if (empty) empty.hidden = true;
}
const url = new URL(picker.dataset.refSearchUrl, window.location.origin);
url.searchParams.set("q", query);
url.searchParams.set("page", String(page));
fetch(url.toString(), { headers: { Accept: "application/json" } })
.then((response) => {
if (!response.ok) throw new Error("Unable to search refs.");
return response.json();
})
.then((data) => {
const current = picker._refPickerSearchState || {};
if (current.token !== token || current.query !== query) return;
renderSearchResults(picker, data.results || [], append);
picker._refPickerSearchState = {
...current,
page,
hasNext: Boolean(data.pagination && data.pagination.has_next),
loading: false,
};
})
.catch(() => {
const current = picker._refPickerSearchState || {};
if (current.token !== token || current.query !== query) return;
renderSearchResults(picker, [], append);
picker._refPickerSearchState = { ...current, loading: false, hasNext: false };
});
};
const resetFilter = (picker) => {
const search = picker.querySelector("[data-ref-picker-search]");
if (search) search.value = "";
searchRefs(picker);
};
const closePicker = (picker) => {
const button = picker.querySelector(".ref-picker-toggle");
const menu = picker.querySelector("[data-ref-picker-menu]");
if (!menu || menu.hidden) return;
menu.hidden = true;
if (button) button.setAttribute("aria-expanded", "false");
};
const openPicker = (picker) => {
pickers.forEach((other) => {
if (other !== picker) closePicker(other);
});
const button = picker.querySelector(".ref-picker-toggle");
const menu = picker.querySelector("[data-ref-picker-menu]");
const search = picker.querySelector("[data-ref-picker-search]");
if (!menu) return;
resetFilter(picker);
menu.hidden = false;
if (button) button.setAttribute("aria-expanded", "true");
if (search) search.focus();
};
pickers.forEach((picker) => {
const button = picker.querySelector(".ref-picker-toggle");
const search = picker.querySelector("[data-ref-picker-search]");
const options = picker.querySelector(".ref-picker-options");
if (button) {
button.addEventListener("click", () => {
const menu = picker.querySelector("[data-ref-picker-menu]");
if (menu && menu.hidden) {
openPicker(picker);
} else {
closePicker(picker);
}
});
}
if (search) {
search.addEventListener("input", () => {
searchRefs(picker);
});
}
if (options) {
options.addEventListener("scroll", () => {
if (options.scrollTop + options.clientHeight >= options.scrollHeight - 32) {
searchRefs(picker, true);
}
});
}
});
document.addEventListener("click", (event) => {
pickers.forEach((picker) => {
if (!picker.contains(event.target)) closePicker(picker);
});
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
pickers.forEach(closePicker);
});
})();
</script>
<script>
(() => {
const search = document.querySelector("[data-repo-search]");
if (!search) return;
const input = search.querySelector("[data-repo-search-input]");
const menu = search.querySelector("[data-repo-search-results]");
const empty = search.querySelector("[data-repo-search-empty]");
if (!input || !menu) return;
let activeToken = 0;
let searchTimeout = null;
let searchState = { query: "", page: 0, hasNext: false, loading: false, token: 0 };
const setOpen = (isOpen) => {
menu.hidden = !isOpen;
input.setAttribute("aria-expanded", isOpen ? "true" : "false");
};
const clearResults = () => {
menu.querySelectorAll("[data-repo-search-result]").forEach((result) => result.remove());
};
const renderResults = (results, append = false) => {
if (!append) clearResults();
results.forEach((result) => {
const item = document.createElement("div");
const link = document.createElement("a");
const title = document.createElement("strong");
const meta = document.createElement("small");
item.className = "repo-search-result";
item.dataset.repoSearchResult = "true";
link.className = "repo-search-link";
link.href = result.url || "#";
link.setAttribute("role", "option");
title.textContent = result.full_name || `${result.owner_username}/${result.name}`;
meta.textContent = (result.star_count || 0) === 1 ? "1 star" : `${result.star_count || 0} stars`;
link.append(title, document.createTextNode(" "), meta);
item.append(link);
if (empty) {
menu.insertBefore(item, empty);
} else {
menu.append(item);
}
});
if (empty) empty.hidden = Boolean(menu.querySelector("[data-repo-search-result]"));
setOpen(Boolean(input.value.trim()));
};
const searchRepos = (append = false) => {
const query = input.value.trim();
if (!query) {
clearResults();
if (empty) empty.hidden = true;
setOpen(false);
searchState = { ...searchState, query: "", page: 0, hasNext: false, loading: false };
return;
}
if (append && (searchState.query !== query || searchState.loading || !searchState.hasNext)) return;
const token = append ? searchState.token : activeToken + 1;
const page = append ? searchState.page + 1 : 1;
activeToken = token;
searchState = { ...searchState, token, query, loading: true };
if (!append) {
clearResults();
if (empty) empty.hidden = true;
}
const url = new URL(search.dataset.repoSearchUrl, window.location.origin);
url.searchParams.set("q", query);
url.searchParams.set("page", String(page));
fetch(url.toString(), { headers: { Accept: "application/json" } })
.then((response) => {
if (!response.ok) throw new Error("Unable to search repositories.");
return response.json();
})
.then((data) => {
if (activeToken !== token || searchState.query !== query) return;
renderResults(data.results || [], append);
searchState = {
...searchState,
page,
hasNext: Boolean(data.pagination && data.pagination.has_next),
loading: false,
};
})
.catch(() => {
if (activeToken !== token || searchState.query !== query) return;
renderResults([], append);
searchState = { ...searchState, loading: false, hasNext: false };
});
};
input.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(searchRepos, 120);
});
input.addEventListener("focus", () => {
if (input.value.trim()) searchRepos();
});
menu.addEventListener("scroll", () => {
if (menu.scrollTop + menu.clientHeight >= menu.scrollHeight - 32) {
searchRepos(true);
}
});
document.addEventListener("click", (event) => {
if (!search.contains(event.target)) setOpen(false);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") setOpen(false);
});
})();
</script>
<script>
(() => {
const forms = document.querySelectorAll("[data-live-search-form]");
if (!forms.length) return;
const importChildren = (target, source) => {
target.replaceChildren(
...Array.from(source.childNodes).map((child) => document.importNode(child, true))
);
};
forms.forEach((form) => {
const input = form.querySelector("[data-live-search-input]");
const resultsSelector = form.dataset.liveSearchResults;
const filtersSelector = form.dataset.liveSearchFilters;
const results = resultsSelector ? document.querySelector(resultsSelector) : null;
if (!input || !results) return;
let searchTimeout = null;
let activeToken = 0;
const buildSearchUrl = () => {
const url = new URL(form.getAttribute("action") || window.location.href, window.location.origin);
const defaultStatus = form.dataset.liveSearchDefaultStatus || "";
url.search = "";
new FormData(form).forEach((value, key) => {
const text = String(value).trim();
if (!text || key === "page") return;
if (key === "status" && text === defaultStatus) return;
url.searchParams.set(key, text);
});
return url;
};
const replaceSearchContent = (html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
const nextResults = doc.querySelector(resultsSelector);
if (!nextResults) throw new Error("Search response did not include results.");
importChildren(results, nextResults);
if (filtersSelector) {
const filters = document.querySelector(filtersSelector);
const nextFilters = doc.querySelector(filtersSelector);
if (filters && nextFilters) importChildren(filters, nextFilters);
}
if (window.gitmanInitPagination) window.gitmanInitPagination(results);
};
const search = () => {
const token = activeToken + 1;
const url = buildSearchUrl();
const searchUrl = url.toString();
activeToken = token;
results.setAttribute("aria-busy", "true");
fetch(searchUrl, { headers: { Accept: "text/html" } })
.then((response) => {
if (!response.ok) throw new Error("Unable to search.");
return response.text();
})
.then((html) => {
if (activeToken !== token || buildSearchUrl().toString() !== searchUrl) return;
replaceSearchContent(html);
if (window.history && window.history.replaceState) {
window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
}
})
.catch(() => {
if (activeToken !== token || buildSearchUrl().toString() !== searchUrl) return;
})
.finally(() => {
if (activeToken === token && buildSearchUrl().toString() === searchUrl) {
results.removeAttribute("aria-busy");
}
});
};
input.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(search, 160);
});
form.addEventListener("submit", (event) => {
event.preventDefault();
clearTimeout(searchTimeout);
search();
});
});
})();
</script>
<script>
(() => {
const initPager = (pager) => {
if (!pager || pager.dataset.paginationInitialized === "true") return;
const scope = pager.closest("[data-live-search-results]") || document;
const list = scope.querySelector("[data-paginated-list]");
if (!list) return;
pager.dataset.paginationInitialized = "true";
let loading = false;
const setLoading = (isLoading) => {
loading = isLoading;
const status = pager.querySelector("[data-pagination-status]");
if (status) status.hidden = !isLoading;
};
const loadNextPage = () => {
const nextUrl = pager.dataset.nextUrl;
if (loading || !nextUrl) return;
setLoading(true);
fetch(nextUrl, { headers: { Accept: "text/html" } })
.then((response) => {
if (!response.ok) throw new Error("Unable to load the next page.");
return response.text();
})
.then((html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
const nextScope = scope === document
? doc
: (scope.id ? doc.getElementById(scope.id) : doc.querySelector("[data-live-search-results]"));
const nextList = (nextScope || doc).querySelector("[data-paginated-list]");
const nextPager = (nextScope || doc).querySelector("[data-pagination]");
if (!nextList) throw new Error("Next page has no list.");
Array.from(nextList.children).forEach((item) => {
list.appendChild(document.importNode(item, true));
});
if (nextPager && nextPager.dataset.nextUrl) {
pager.dataset.nextUrl = nextPager.dataset.nextUrl;
setLoading(false);
} else {
pager.remove();
}
})
.catch(() => {
setLoading(false);
});
};
if ("IntersectionObserver" in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) loadNextPage();
});
}, { rootMargin: "600px 0px" });
observer.observe(pager);
return;
}
window.addEventListener("scroll", () => {
const bottom = pager.getBoundingClientRect().top - window.innerHeight;
if (bottom < 600) loadNextPage();
});
};
window.gitmanInitPagination = (scope = document) => {
const root = scope && scope.querySelectorAll ? scope : document;
if (root.matches && root.matches("[data-pagination]")) {
initPager(root);
return;
}
root.querySelectorAll("[data-pagination]").forEach(initPager);
};
window.gitmanInitPagination();
})();
</script>
<script>
(() => {
const forms = document.querySelectorAll("[data-import-bundle-form]");
if (!forms.length) return;
const replaceDocument = (html) => {
document.open();
document.write(html);
document.close();
};
const responseMessage = async (response, fallback) => {
try {
const text = (await response.text()).trim();
return text || fallback;
} catch (error) {
return fallback;
}
};
const uploadChunk = (url, body, csrfToken, onProgress, onUploadComplete) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
xhr.upload.addEventListener("progress", (progressEvent) => {
if (progressEvent.lengthComputable) onProgress(progressEvent.loaded);
});
xhr.upload.addEventListener("load", onUploadComplete);
xhr.addEventListener("load", () => {
resolve({
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
text: async () => xhr.responseText || "",
});
});
xhr.addEventListener("error", () => reject(new Error("Upload failed. Check your connection and try again.")));
xhr.addEventListener("abort", () => reject(new Error("Upload canceled.")));
xhr.send(body);
});
};
forms.forEach((form) => {
form.addEventListener("submit", async (event) => {
const input = form.querySelector("[data-import-bundle-file]");
const file = input && input.files ? input.files[0] : null;
if (!file || !window.XMLHttpRequest) return;
event.preventDefault();
const status = form.querySelector("[data-import-bundle-status]");
const button = form.querySelector("button[type='submit']");
const fileControl = input ? input.closest("label") || input : null;
const csrf = form.querySelector('input[name="_csrf_token"]');
const url = new URL(form.dataset.uploadUrl, window.location.origin);
url.searchParams.set("filename", file.name || "repo.bundle");
if (file.size <= 0) return;
const setStatus = (message, className = "muted") => {
if (!status) return;
status.className = className;
status.textContent = message;
status.hidden = false;
};
const setUploadStatus = (loadedBytes) => {
const percentage = Math.min(100, Math.floor((loadedBytes / file.size) * 100));
setStatus(`Uploading Git bundle... ${percentage}% - Do not leave this page.`);
};
setUploadStatus(0);
if (button) {
button.dataset.originalLabel = button.textContent;
button.disabled = true;
button.hidden = true;
}
if (input) {
input.disabled = true;
}
if (fileControl) {
fileControl.hidden = true;
}
try {
const chunkSize = 4 * 1024 * 1024;
const uploadId = (
window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : `${Date.now()}-${Math.random()}`
).replace(/[^A-Za-z0-9._-]/g, "");
let offset = 0;
let finalResponse = null;
while (offset < file.size) {
const end = Math.min(offset + chunkSize, file.size);
const isFinalChunk = end >= file.size;
url.searchParams.set("upload_id", uploadId);
url.searchParams.set("offset", String(offset));
url.searchParams.set("total", String(file.size));
let response = null;
let lastError = null;
for (let attempt = 1; attempt <= 8; attempt += 1) {
try {
url.searchParams.set("retry", String(attempt));
response = await uploadChunk(
url.toString(),
file.slice(offset, end),
csrf ? csrf.value : "",
(loadedBytes) => setUploadStatus(offset + loadedBytes),
() => {
if (isFinalChunk) {
setStatus("Finalizing import... You can check back in a few minutes.");
} else {
setUploadStatus(end);
}
}
);
if (response.ok) break;
lastError = new Error(
await responseMessage(response, "Upload failed.")
);
if (response.status >= 400 && response.status < 500) break;
} catch (error) {
lastError = error;
}
if (attempt < 8) {
setStatus(`Uploading Git bundle... ${Math.floor((offset / file.size) * 100)}% - Do not leave this page. Retrying chunk ${attempt}/7...`);
await new Promise((resolve) => setTimeout(resolve, Math.min(attempt * 2000, 15000)));
}
}
if (!response || !response.ok) throw lastError || new Error("Upload failed.");
offset = end;
if (offset < file.size) {
setUploadStatus(offset);
} else {
finalResponse = response;
}
}
replaceDocument(await finalResponse.text());
} catch (error) {
setStatus(
error && error.message
? error.message
: "Upload failed. Check your connection and try again.",
"alert"
);
if (button) {
button.disabled = false;
button.hidden = false;
button.textContent = button.dataset.originalLabel || "Import bundle";
}
if (input) {
input.disabled = false;
}
if (fileControl) {
fileControl.hidden = false;
}
}
});
});
})();
</script>
</body>
</html>