mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-07 01:28:46 +00:00
5363 lines
186 KiB
JavaScript
5363 lines
186 KiB
JavaScript
const view = document.getElementById("view");
|
|
const breadcrumbs = document.getElementById("breadcrumbs");
|
|
const repositoryForm = document.getElementById("repository-form");
|
|
const repositoryInput = document.getElementById("repository-input");
|
|
const repositoryCombobox = document.getElementById("repository-combobox");
|
|
const repositoryMenuButton = document.getElementById("repository-menu-button");
|
|
const repositoryPanel = document.getElementById("repository-panel");
|
|
const repositoryResults = document.getElementById("repository-results");
|
|
const accountButton = document.getElementById("account-button");
|
|
const accountAvatar = document.getElementById("account-avatar");
|
|
const accountFallback = document.getElementById("account-fallback");
|
|
const cacheLabel = document.getElementById("cache-label");
|
|
const toastRegion = document.getElementById("toast-region");
|
|
const capabilityToken =
|
|
document.querySelector('meta[name="pr-artifact-explorer-token"]')?.content ?? "";
|
|
|
|
let bootstrapState = null;
|
|
let currentArtifact = null;
|
|
let currentPullContext = null;
|
|
let currentPlayer = null;
|
|
let currentPlayerIsPlaying = false;
|
|
let artifactFilters = {
|
|
context: null,
|
|
query: "",
|
|
status: "all",
|
|
run: "latest",
|
|
};
|
|
const artifactDownloadProgress = new Map();
|
|
const LARGE_ARTIFACT_PROGRESS_BYTES = 2 * 1024 * 1024;
|
|
const MAX_VISIBLE_ARTIFACT_FILES = 1_000;
|
|
const EXPAND_ALL_FILE_TREE_LIMIT = 200;
|
|
const PULL_PAYLOAD_CACHE_FRESH_MS = 30 * 1_000;
|
|
const PULL_PAYLOAD_CACHE_STALE_MS = 10 * 60 * 1_000;
|
|
const PULL_PAYLOAD_CACHE_LIMIT = 30;
|
|
const PULL_QUERY_QUALIFIERS = [
|
|
{
|
|
name: "in",
|
|
description: "Search within a field",
|
|
values: [
|
|
["title", "Pull request titles"],
|
|
["body", "Pull request descriptions"],
|
|
["comments", "Pull request comments"],
|
|
],
|
|
},
|
|
{
|
|
name: "is",
|
|
description: "Filter by state or type",
|
|
values: [
|
|
["open", "Open pull requests"],
|
|
["closed", "Closed pull requests"],
|
|
["merged", "Merged pull requests"],
|
|
["unmerged", "Pull requests that are not merged"],
|
|
["draft", "Draft pull requests"],
|
|
["pr", "Pull requests only"],
|
|
],
|
|
},
|
|
{
|
|
name: "author",
|
|
description: "Opened by a user",
|
|
dynamicValues: "authors",
|
|
},
|
|
{
|
|
name: "assignee",
|
|
description: "Assigned to a user",
|
|
dynamicValues: "authors",
|
|
},
|
|
{
|
|
name: "label",
|
|
description: "Has a label",
|
|
dynamicValues: "labels",
|
|
},
|
|
{
|
|
name: "review",
|
|
description: "Filter by review state",
|
|
values: [
|
|
["none", "No reviews"],
|
|
["required", "Review required"],
|
|
["approved", "Approved"],
|
|
["changes_requested", "Changes requested"],
|
|
],
|
|
},
|
|
{
|
|
name: "reviewed-by",
|
|
description: "Reviewed by a user",
|
|
dynamicValues: "authors",
|
|
},
|
|
{
|
|
name: "review-requested",
|
|
description: "Review requested from a user",
|
|
dynamicValues: "authors",
|
|
},
|
|
{
|
|
name: "draft",
|
|
description: "Filter by draft state",
|
|
values: [
|
|
["true", "Draft pull requests"],
|
|
["false", "Ready for review"],
|
|
],
|
|
},
|
|
{
|
|
name: "status",
|
|
description: "Filter by commit status",
|
|
values: [
|
|
["success", "Checks succeeded"],
|
|
["failure", "Checks failed"],
|
|
["pending", "Checks are pending"],
|
|
],
|
|
},
|
|
{
|
|
name: "no",
|
|
description: "Exclude missing metadata",
|
|
values: [
|
|
["label", "No labels"],
|
|
["assignee", "No assignee"],
|
|
["milestone", "No milestone"],
|
|
["project", "No project"],
|
|
],
|
|
},
|
|
{
|
|
name: "sort",
|
|
description: "Choose result order",
|
|
values: [
|
|
["updated-desc", "Recently updated first"],
|
|
["updated-asc", "Least recently updated first"],
|
|
["created-desc", "Newest first"],
|
|
["created-asc", "Oldest first"],
|
|
["comments-desc", "Most commented first"],
|
|
["comments-asc", "Least commented first"],
|
|
],
|
|
},
|
|
{ name: "base", description: "Targets a base branch" },
|
|
{ name: "head", description: "Comes from a head branch" },
|
|
{ name: "milestone", description: "Belongs to a milestone" },
|
|
{ name: "mentions", description: "Mentions a user" },
|
|
{ name: "commenter", description: "Commented on by a user" },
|
|
{ name: "involves", description: "Involves a user" },
|
|
{ name: "created", description: "Created on a date or range" },
|
|
{ name: "updated", description: "Updated on a date or range" },
|
|
{ name: "closed", description: "Closed on a date or range" },
|
|
{ name: "merged", description: "Merged on a date or range" },
|
|
];
|
|
const MANIFEST_FILE_NAMES = new Set([
|
|
"bun.lock",
|
|
"bun.lockb",
|
|
"cargo.lock",
|
|
"cargo.toml",
|
|
"composer.json",
|
|
"composer.lock",
|
|
"compose.yaml",
|
|
"compose.yml",
|
|
"deno.json",
|
|
"deno.jsonc",
|
|
"directory.build.props",
|
|
"directory.build.targets",
|
|
"dockerfile",
|
|
"gemfile",
|
|
"gemfile.lock",
|
|
"global.json",
|
|
"go.mod",
|
|
"go.sum",
|
|
"mix.exs",
|
|
"mix.lock",
|
|
"npm-shrinkwrap.json",
|
|
"nuget.config",
|
|
"package-lock.json",
|
|
"package.json",
|
|
"pipfile",
|
|
"pipfile.lock",
|
|
"pnpm-lock.yaml",
|
|
"poetry.lock",
|
|
"pubspec.yaml",
|
|
"pyproject.toml",
|
|
"yarn.lock",
|
|
]);
|
|
const VENDORED_PATH_SEGMENTS = new Set([
|
|
".pnpm",
|
|
".venv",
|
|
"__pycache__",
|
|
"bower_components",
|
|
"node_modules",
|
|
"site-packages",
|
|
"third-party",
|
|
"third_party",
|
|
"vendor",
|
|
"vendors",
|
|
]);
|
|
const FILE_PATH_COLLATOR = new Intl.Collator("en", {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
const pullPayloadCache = new Map();
|
|
let renderSequence = 0;
|
|
let repositorySearchTimer = null;
|
|
let repositorySearchController = null;
|
|
let repositorySearchSequence = 0;
|
|
let repositoryRemoteResults = [];
|
|
let repositorySearchState = "idle";
|
|
let repositorySearchError = null;
|
|
let repositoryActiveIndex = -1;
|
|
let repositoryPickerQuery = "";
|
|
let pullAuthors = [];
|
|
let pullAuthorsRepository = null;
|
|
let pullAuthorsState = "idle";
|
|
let pullAuthorsError = null;
|
|
let pullAuthorsLoadSequence = 0;
|
|
let pullQueryLabels = [];
|
|
let pullQuerySuggestionIndex = -1;
|
|
let pullQueryPendingCommit = null;
|
|
let pullTableRefreshSequence = 0;
|
|
let pullFilterStorageWarningShown = false;
|
|
const PULL_FILTER_STORAGE_KEY = "pr-artifact-explorer:pull-filters:v1";
|
|
const MAX_LOCAL_PULL_FILTER_REPOSITORIES = 50;
|
|
const previewThemeMedia = window.matchMedia("(prefers-color-scheme: dark)");
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function titleHtml(value) {
|
|
const source = String(value ?? "");
|
|
let result = "";
|
|
let cursor = 0;
|
|
|
|
while (cursor < source.length) {
|
|
const opening = source.indexOf("`", cursor);
|
|
if (opening === -1) {
|
|
result += escapeHtml(source.slice(cursor));
|
|
break;
|
|
}
|
|
|
|
result += escapeHtml(source.slice(cursor, opening));
|
|
let runLength = 1;
|
|
while (source[opening + runLength] === "`") runLength++;
|
|
const delimiter = "`".repeat(runLength);
|
|
let closing = source.indexOf(delimiter, opening + runLength);
|
|
while (
|
|
closing !== -1 &&
|
|
(source[closing - 1] === "`" || source[closing + runLength] === "`")
|
|
) {
|
|
closing = source.indexOf(delimiter, closing + runLength);
|
|
}
|
|
|
|
if (closing === -1) {
|
|
result += escapeHtml(source.slice(opening));
|
|
break;
|
|
}
|
|
|
|
let code = source.slice(opening + runLength, closing).replace(/\s+/g, " ");
|
|
if (code.startsWith(" ") && code.endsWith(" ") && code.trim()) {
|
|
code = code.slice(1, -1);
|
|
}
|
|
result += `<code class="title-inline-code">${escapeHtml(code)}</code>`;
|
|
cursor = closing + runLength;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function highlightXmlTag(token) {
|
|
const opening = token.startsWith("</") ? "</" : "<";
|
|
const ending = token.endsWith("/>") ? "/>" : ">";
|
|
const nameStart = opening.length;
|
|
const name = token.slice(nameStart).match(/^[^\s/>]+/)?.[0];
|
|
if (!name) {
|
|
return `<span class="syntax-meta">${escapeHtml(token)}</span>`;
|
|
}
|
|
|
|
const attributesStart = nameStart + name.length;
|
|
const attributesEnd = token.length - ending.length;
|
|
const attributes = token.slice(attributesStart, attributesEnd);
|
|
const matcher = /([^\s=/>]+)(\s*=\s*)("(?:[^"]*)"|'(?:[^']*)'|[^\s>]+)/g;
|
|
let highlightedAttributes = "";
|
|
let cursor = 0;
|
|
for (const match of attributes.matchAll(matcher)) {
|
|
highlightedAttributes += escapeHtml(attributes.slice(cursor, match.index));
|
|
highlightedAttributes += `<span class="syntax-attribute">${escapeHtml(match[1])}</span>`;
|
|
highlightedAttributes += `<span class="syntax-punctuation">${escapeHtml(match[2])}</span>`;
|
|
highlightedAttributes += `<span class="syntax-string">${escapeHtml(match[3])}</span>`;
|
|
cursor = match.index + match[0].length;
|
|
}
|
|
highlightedAttributes += escapeHtml(attributes.slice(cursor));
|
|
|
|
return [
|
|
`<span class="syntax-punctuation">${escapeHtml(opening)}</span>`,
|
|
`<span class="syntax-tag">${escapeHtml(name)}</span>`,
|
|
highlightedAttributes,
|
|
`<span class="syntax-punctuation">${escapeHtml(ending)}</span>`,
|
|
].join("");
|
|
}
|
|
|
|
function highlightXml(value) {
|
|
const source = String(value ?? "");
|
|
let highlighted = "";
|
|
let cursor = 0;
|
|
while (cursor < source.length) {
|
|
const opening = source.indexOf("<", cursor);
|
|
if (opening === -1) {
|
|
highlighted += escapeHtml(source.slice(cursor));
|
|
break;
|
|
}
|
|
highlighted += escapeHtml(source.slice(cursor, opening));
|
|
|
|
let closing;
|
|
if (source.startsWith("<!--", opening)) {
|
|
const index = source.indexOf("-->", opening + 4);
|
|
closing = index === -1 ? source.length : index + 3;
|
|
} else if (source.startsWith("<![CDATA[", opening)) {
|
|
const index = source.indexOf("]]>", opening + 9);
|
|
closing = index === -1 ? source.length : index + 3;
|
|
} else {
|
|
let quote = null;
|
|
let subsetDepth = 0;
|
|
let foundClosing = false;
|
|
closing = source.length;
|
|
for (let index = opening + 1; index < source.length; index++) {
|
|
const character = source[index];
|
|
if (quote) {
|
|
if (character === quote) quote = null;
|
|
continue;
|
|
}
|
|
if (character === '"' || character === "'") {
|
|
quote = character;
|
|
} else if (character === "[") {
|
|
subsetDepth++;
|
|
} else if (character === "]" && subsetDepth > 0) {
|
|
subsetDepth--;
|
|
} else if (character === ">" && subsetDepth === 0) {
|
|
closing = index + 1;
|
|
foundClosing = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!foundClosing) {
|
|
highlighted += escapeHtml(source.slice(opening));
|
|
break;
|
|
}
|
|
}
|
|
|
|
const token = source.slice(opening, closing);
|
|
if (token.startsWith("<!--")) {
|
|
highlighted += `<span class="syntax-comment">${escapeHtml(token)}</span>`;
|
|
} else if (token.startsWith("<![CDATA[")) {
|
|
highlighted += `<span class="syntax-cdata">${escapeHtml(token)}</span>`;
|
|
} else if (token.startsWith("<?") || token.startsWith("<!")) {
|
|
highlighted += `<span class="syntax-meta">${escapeHtml(token)}</span>`;
|
|
} else {
|
|
highlighted += highlightXmlTag(token);
|
|
}
|
|
cursor = closing;
|
|
}
|
|
return highlighted;
|
|
}
|
|
|
|
function highlightJson(value) {
|
|
const source = String(value ?? "");
|
|
const matcher = /(?<key>"(?:\\.|[^"\\])*")(?=\s*:)|(?<string>"(?:\\.|[^"\\])*")|(?<number>-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|(?<boolean>\b(?:true|false)\b)|(?<nil>\bnull\b)/g;
|
|
let highlighted = "";
|
|
let cursor = 0;
|
|
for (const match of source.matchAll(matcher)) {
|
|
highlighted += escapeHtml(source.slice(cursor, match.index));
|
|
const tokenKind = match.groups.key
|
|
? "key"
|
|
: match.groups.string
|
|
? "string"
|
|
: match.groups.number
|
|
? "number"
|
|
: match.groups.boolean
|
|
? "boolean"
|
|
: "null";
|
|
highlighted += `<span class="syntax-${tokenKind}">${escapeHtml(match[0])}</span>`;
|
|
cursor = match.index + match[0].length;
|
|
}
|
|
return highlighted + escapeHtml(source.slice(cursor));
|
|
}
|
|
|
|
function icon(name, className = "") {
|
|
return `<span class="octicon icon-${escapeHtml(name)} ${escapeHtml(className)}" aria-hidden="true"></span>`;
|
|
}
|
|
|
|
function formatBytes(value) {
|
|
const bytes = Number(value) || 0;
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
const units = ["KiB", "MiB", "GiB", "TiB"];
|
|
let size = bytes;
|
|
let unit = -1;
|
|
do {
|
|
size /= 1024;
|
|
unit++;
|
|
} while (size >= 1024 && unit < units.length - 1);
|
|
const digits = size >= 10 || unit === 0 ? 0 : 1;
|
|
return `${size.toFixed(digits)} ${units[unit]}`;
|
|
}
|
|
|
|
function formatRemainingTime(value) {
|
|
const seconds = Number(value);
|
|
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
if (seconds < 60) return `${Math.max(1, Math.ceil(seconds))}s left`;
|
|
const minutes = Math.ceil(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m left`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainingMinutes = minutes % 60;
|
|
return remainingMinutes
|
|
? `${hours}h ${remainingMinutes}m left`
|
|
: `${hours}h left`;
|
|
}
|
|
|
|
function resolvedCanvasTheme() {
|
|
for (const element of [document.documentElement, document.body]) {
|
|
const tone = element?.getAttribute("data-theme-tone");
|
|
if (tone === "light" || tone === "dark") return tone;
|
|
const mode = element?.getAttribute("data-color-mode");
|
|
if (mode === "light" || mode === "dark") return mode;
|
|
}
|
|
return previewThemeMedia.matches ? "dark" : "light";
|
|
}
|
|
|
|
function syncPreviewTheme() {
|
|
const theme = resolvedCanvasTheme();
|
|
for (const frame of document.querySelectorAll("iframe[data-theme-aware-preview]")) {
|
|
frame.style.colorScheme = theme;
|
|
try {
|
|
frame.contentWindow?.postMessage(
|
|
{ type: "copilot-preview-theme", theme },
|
|
new URL(frame.src).origin,
|
|
);
|
|
} catch {
|
|
// The preview may be navigating between artifact pages.
|
|
}
|
|
}
|
|
}
|
|
|
|
function installPreviewThemeSync() {
|
|
const observer = new MutationObserver(syncPreviewTheme);
|
|
const options = {
|
|
attributes: true,
|
|
attributeFilter: ["data-color-mode", "data-theme-tone"],
|
|
};
|
|
observer.observe(document.documentElement, options);
|
|
observer.observe(document.body, options);
|
|
previewThemeMedia.addEventListener("change", syncPreviewTheme);
|
|
}
|
|
|
|
function relativeTime(value) {
|
|
const timestamp = new Date(value).getTime();
|
|
if (!Number.isFinite(timestamp)) return "unknown time";
|
|
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
|
|
if (seconds < 60) return "just now";
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 30) return `${days}d ago`;
|
|
const months = Math.floor(days / 30);
|
|
if (months < 12) return `${months}mo ago`;
|
|
return `${Math.floor(months / 12)}y ago`;
|
|
}
|
|
|
|
function formatDate(value) {
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime())
|
|
? "Unknown"
|
|
: new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(date);
|
|
}
|
|
|
|
function normalizeRepository(value) {
|
|
const repository = String(value ?? "")
|
|
.trim()
|
|
.replace(/^https?:\/\/github\.com\//i, "")
|
|
.replace(/\.git$/i, "");
|
|
const parts = repository.split("/");
|
|
if (
|
|
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
|
parts.some((part) => part === "." || part === "..")
|
|
) {
|
|
throw new Error("Enter a repository as owner/name or paste a pull request URL.");
|
|
}
|
|
return repository;
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const headers = { ...(options.headers ?? {}) };
|
|
headers["x-pr-artifact-explorer-token"] = capabilityToken;
|
|
let body = options.body;
|
|
if (body != null && typeof body !== "string") {
|
|
headers["Content-Type"] = "application/json";
|
|
body = JSON.stringify(body);
|
|
}
|
|
const response = await fetch(path, {
|
|
...options,
|
|
headers,
|
|
body,
|
|
});
|
|
const type = response.headers.get("content-type") ?? "";
|
|
const payload = type.includes("json")
|
|
? await response.json().catch(() => null)
|
|
: await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(payload?.error ?? payload?.message ?? `Request failed (${response.status}).`);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function showToast(message, kind = "info") {
|
|
const node = document.createElement("div");
|
|
node.className = `toast-message${kind === "error" ? " error" : ""}`;
|
|
node.textContent = message;
|
|
toastRegion.append(node);
|
|
window.setTimeout(() => node.remove(), 4_500);
|
|
}
|
|
|
|
function reportPullFilterStorageError(error) {
|
|
if (pullFilterStorageWarningShown) return;
|
|
pullFilterStorageWarningShown = true;
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
showToast(`Saved pull filters are unavailable. ${detail}`, "error");
|
|
}
|
|
|
|
function pullFilterRepositoryKey(repository) {
|
|
return normalizeRepository(repository).toLocaleLowerCase();
|
|
}
|
|
|
|
function normalizePullFilterPreference(value) {
|
|
const updatedAt = Number(value?.updatedAt);
|
|
return {
|
|
state: ["open", "closed", "all"].includes(value?.state) ? value.state : "open",
|
|
author:
|
|
typeof value?.author === "string" && value.author.trim()
|
|
? value.author.trim().slice(0, 100)
|
|
: null,
|
|
artifacts: ["with", "without"].includes(value?.artifacts)
|
|
? value.artifacts
|
|
: "all",
|
|
ci: ["failing", "passing", "pending", "none"].includes(value?.ci)
|
|
? value.ci
|
|
: "all",
|
|
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0,
|
|
};
|
|
}
|
|
|
|
function readLocalPullFilterStore() {
|
|
try {
|
|
const raw = localStorage.getItem(PULL_FILTER_STORAGE_KEY);
|
|
if (!raw) return {};
|
|
const value = JSON.parse(raw);
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error("The local filter store has an invalid shape.");
|
|
}
|
|
return value;
|
|
} catch (error) {
|
|
reportPullFilterStorageError(error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeLocalPullFilterPreference(repository, preference) {
|
|
const store = readLocalPullFilterStore();
|
|
if (!store) return;
|
|
const key = pullFilterRepositoryKey(repository);
|
|
const entries = Object.entries({
|
|
...store,
|
|
[key]: normalizePullFilterPreference(preference),
|
|
})
|
|
.filter(([, value]) => value && typeof value === "object")
|
|
.sort(
|
|
(left, right) =>
|
|
(Number(right[1].updatedAt) || 0) - (Number(left[1].updatedAt) || 0),
|
|
)
|
|
.slice(0, MAX_LOCAL_PULL_FILTER_REPOSITORIES);
|
|
try {
|
|
localStorage.setItem(PULL_FILTER_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
|
|
} catch (error) {
|
|
reportPullFilterStorageError(error);
|
|
}
|
|
}
|
|
|
|
function savedPullFilterPreference(repository) {
|
|
const key = pullFilterRepositoryKey(repository);
|
|
const local = readLocalPullFilterStore();
|
|
if (local?.[key] && typeof local[key] === "object") {
|
|
return normalizePullFilterPreference(local[key]);
|
|
}
|
|
const durable = bootstrapState?.prefs?.pullFilters?.[key];
|
|
if (durable && typeof durable === "object") {
|
|
const preference = normalizePullFilterPreference(durable);
|
|
writeLocalPullFilterPreference(repository, preference);
|
|
return preference;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function persistPullFilterPreferenceClient(repository, value) {
|
|
const key = pullFilterRepositoryKey(repository);
|
|
const preference = normalizePullFilterPreference({
|
|
...value,
|
|
updatedAt: Date.now(),
|
|
});
|
|
bootstrapState.prefs.pullFilters ??= {};
|
|
bootstrapState.prefs.pullFilters[key] = preference;
|
|
writeLocalPullFilterPreference(repository, preference);
|
|
return preference;
|
|
}
|
|
|
|
function pullsHash(repository, state = "open", filter = null, pullFilters = {}) {
|
|
const query = new URLSearchParams({ state });
|
|
if (typeof filter === "string" && filter.trim()) query.set("q", filter.trim());
|
|
if (pullFilters.artifacts === "with" || pullFilters.artifacts === "without") {
|
|
query.set("artifacts", pullFilters.artifacts);
|
|
}
|
|
if (["failing", "passing", "pending", "none"].includes(pullFilters.ci)) {
|
|
query.set("ci", pullFilters.ci);
|
|
}
|
|
return `#/pulls/${encodeURIComponent(repository)}?${query}`;
|
|
}
|
|
|
|
function pullsHashForRepository(
|
|
repository,
|
|
fallbackState = bootstrapState?.prefs?.pullState ?? "open",
|
|
) {
|
|
const preference = savedPullFilterPreference(repository);
|
|
if (!preference) return pullsHash(repository, fallbackState);
|
|
let query = pullQueryForState("", preference.state);
|
|
query = pullQueryForAuthor(query, preference.author);
|
|
return pullsHash(repository, preference.state, query, preference);
|
|
}
|
|
|
|
function pullHash(repository, pullNumber) {
|
|
return `#/pull/${encodeURIComponent(repository)}/${Number(pullNumber)}`;
|
|
}
|
|
|
|
function artifactHash(repository, pullNumber, artifactId, filePath = null) {
|
|
const query = filePath ? `?file=${encodeURIComponent(filePath)}` : "";
|
|
return `#/artifact/${encodeURIComponent(repository)}/${Number(pullNumber)}/${Number(artifactId)}${query}`;
|
|
}
|
|
|
|
function parseRoute() {
|
|
const raw = location.hash.startsWith("#/") ? location.hash.slice(2) : "";
|
|
const queryIndex = raw.indexOf("?");
|
|
const path = queryIndex >= 0 ? raw.slice(0, queryIndex) : raw;
|
|
const query = new URLSearchParams(queryIndex >= 0 ? raw.slice(queryIndex + 1) : "");
|
|
const parts = path.split("/").filter(Boolean);
|
|
|
|
if (parts[0] === "pulls" && parts[1]) {
|
|
return {
|
|
name: "pulls",
|
|
repository: decodeURIComponent(parts[1]),
|
|
state: query.get("state") ?? "open",
|
|
query: query.get("q"),
|
|
artifacts: query.get("artifacts") ?? "all",
|
|
ci: query.get("ci") ?? "all",
|
|
};
|
|
}
|
|
if (parts[0] === "pull" && parts[1] && parts[2]) {
|
|
return {
|
|
name: "pull",
|
|
repository: decodeURIComponent(parts[1]),
|
|
pullNumber: Number(parts[2]),
|
|
};
|
|
}
|
|
if (parts[0] === "artifact" && parts[1] && parts[2] && parts[3]) {
|
|
return {
|
|
name: "artifact",
|
|
repository: decodeURIComponent(parts[1]),
|
|
pullNumber: Number(parts[2]),
|
|
artifactId: Number(parts[3]),
|
|
filePath: query.get("file"),
|
|
};
|
|
}
|
|
if (parts[0] === "cache") return { name: "cache" };
|
|
if (parts[0] === "accounts") return { name: "accounts" };
|
|
return {
|
|
name: "pulls",
|
|
repository: bootstrapState?.prefs?.repository ?? "microsoft/aspire",
|
|
state: bootstrapState?.prefs?.pullState ?? "open",
|
|
};
|
|
}
|
|
|
|
function breadcrumbLabelHtml(item) {
|
|
const label = String(item.label ?? "");
|
|
const compactLabel = String(item.compactLabel ?? "");
|
|
const hasCompactLabel = compactLabel && compactLabel !== label;
|
|
return `
|
|
<span class="breadcrumb-item-label${hasCompactLabel ? " has-compact-label" : ""}" title="${escapeHtml(label)}">
|
|
<span class="breadcrumb-label-full">${escapeHtml(label)}</span>
|
|
${hasCompactLabel ? `<span class="breadcrumb-label-compact">${escapeHtml(compactLabel)}</span>` : ""}
|
|
</span>`;
|
|
}
|
|
|
|
function breadcrumbHref(item) {
|
|
const href = typeof item?.href === "string" ? item.href.trim() : "";
|
|
return href || null;
|
|
}
|
|
|
|
function setBreadcrumbOverflowOpen(open, focusFirst = false) {
|
|
const trigger = breadcrumbs.querySelector("[data-toggle-breadcrumb-overflow]");
|
|
const menu = breadcrumbs.querySelector("#breadcrumb-overflow-menu");
|
|
if (!trigger || !menu) return;
|
|
const isOpen = Boolean(open);
|
|
trigger.setAttribute("aria-expanded", String(isOpen));
|
|
menu.hidden = !isOpen;
|
|
trigger.closest(".breadcrumb-overflow")?.classList.toggle("is-open", isOpen);
|
|
if (isOpen && focusFirst) {
|
|
[...menu.querySelectorAll('[role="menuitem"]')]
|
|
.find((item) => item.getClientRects().length > 0)
|
|
?.focus();
|
|
}
|
|
}
|
|
|
|
function setBreadcrumbs(items) {
|
|
const values = items.filter((item) => item?.label);
|
|
const hasOverflow = values.length > 3;
|
|
const overflowItems = values.slice(0, -1);
|
|
const overflowHtml = hasOverflow
|
|
? `
|
|
<li class="breadcrumb-item breadcrumb-overflow">
|
|
<button
|
|
class="breadcrumb-overflow-trigger"
|
|
type="button"
|
|
data-toggle-breadcrumb-overflow
|
|
aria-label="Show parent pages"
|
|
aria-haspopup="menu"
|
|
aria-expanded="false"
|
|
aria-controls="breadcrumb-overflow-menu"
|
|
title="Show parent pages"
|
|
>${icon("kebab-horizontal")}</button>
|
|
<div
|
|
id="breadcrumb-overflow-menu"
|
|
class="breadcrumb-overflow-menu"
|
|
role="menu"
|
|
aria-label="Parent pages"
|
|
hidden
|
|
>
|
|
<div class="breadcrumb-overflow-header">Parent pages</div>
|
|
${overflowItems
|
|
.map((item, index) => {
|
|
const href = breadcrumbHref(item);
|
|
const classes = `breadcrumb-overflow-option${index === 0 ? " breadcrumb-overflow-option-root" : ""}`;
|
|
return href
|
|
? `
|
|
<a
|
|
class="${classes}"
|
|
href="${escapeHtml(href)}"
|
|
role="menuitem"
|
|
title="${escapeHtml(item.label)}"
|
|
>
|
|
<span>${escapeHtml(item.label)}</span>
|
|
</a>`
|
|
: `
|
|
<div
|
|
class="${classes} breadcrumb-overflow-option-static"
|
|
aria-disabled="true"
|
|
title="${escapeHtml(item.label)}"
|
|
>
|
|
<span>${escapeHtml(item.label)}</span>
|
|
</div>`;
|
|
})
|
|
.join("")}
|
|
</div>
|
|
</li>`
|
|
: "";
|
|
breadcrumbs.innerHTML = `
|
|
<ol class="breadcrumbs${hasOverflow ? " has-overflow" : ""}">
|
|
${values
|
|
.map((item, index) => {
|
|
const current = index === values.length - 1;
|
|
const href = breadcrumbHref(item);
|
|
const classes = [
|
|
"breadcrumb-item",
|
|
index === 0 ? "breadcrumb-item-root" : "",
|
|
!current && index > 0 ? "breadcrumb-item-collapsible" : "",
|
|
current ? "breadcrumb-item-current" : "",
|
|
!current && !href ? "breadcrumb-item-static" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
const itemHtml = `<li class="${classes}"${current ? ' aria-current="page"' : ""}>${
|
|
current || !href
|
|
? breadcrumbLabelHtml(item)
|
|
: `<a href="${escapeHtml(href)}">${breadcrumbLabelHtml(item)}</a>`
|
|
}</li>`;
|
|
return index === 0 ? `${itemHtml}${overflowHtml}` : itemHtml;
|
|
})
|
|
.join("")}
|
|
</ol>`;
|
|
}
|
|
|
|
function loadingView() {
|
|
view.innerHTML = `
|
|
<div class="loading-list" aria-label="Loading">
|
|
${Array.from({ length: 6 }, () => `
|
|
<div class="skeleton-row">
|
|
<span class="skeleton"></span>
|
|
<span><span class="skeleton d-block"></span><span class="skeleton short d-block"></span></span>
|
|
<span class="skeleton"></span>
|
|
</div>`).join("")}
|
|
</div>`;
|
|
}
|
|
|
|
function renderError(error, title = "Unable to load this view") {
|
|
view.innerHTML = `
|
|
<div class="flash flash-error">
|
|
<strong>${escapeHtml(title)}</strong>
|
|
<div class="mt-1">${escapeHtml(error instanceof Error ? error.message : String(error))}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function updateCacheHeader(cache) {
|
|
if (!cache) return;
|
|
bootstrapState.cache = cache;
|
|
cacheLabel.textContent = cache.count
|
|
? `Cache ${formatBytes(cache.totalBytes)}`
|
|
: "Cache";
|
|
}
|
|
|
|
function renderAccounts(accounts, active) {
|
|
const values = Array.isArray(accounts) ? accounts : [];
|
|
bootstrapState.accounts = values;
|
|
bootstrapState.account = active ?? null;
|
|
if (active?.id) bootstrapState.prefs.account = active.id;
|
|
|
|
accountButton.title = active
|
|
? `${active.login} - ${
|
|
active.repositoryAccess === false ? "limited repository access" : "GitHub account"
|
|
}`
|
|
: "No GitHub account detected";
|
|
accountButton.setAttribute(
|
|
"aria-label",
|
|
active ? `GitHub account: ${active.login}` : "No GitHub account detected",
|
|
);
|
|
accountButton.classList.toggle("is-active", parseRoute().name === "accounts");
|
|
if (active?.avatarUrl) {
|
|
accountAvatar.src = active.avatarUrl;
|
|
accountAvatar.alt = `${active.login} avatar`;
|
|
accountAvatar.hidden = false;
|
|
accountFallback.hidden = true;
|
|
} else {
|
|
accountAvatar.removeAttribute("src");
|
|
accountAvatar.alt = "";
|
|
accountAvatar.hidden = true;
|
|
accountFallback.hidden = false;
|
|
}
|
|
}
|
|
|
|
function updateAccountFromPayload(payload) {
|
|
if (payload?.accounts) renderAccounts(payload.accounts, payload.account);
|
|
if (payload?.account && !payload.accounts) {
|
|
bootstrapState.account = payload.account;
|
|
renderAccounts(bootstrapState.accounts, payload.account);
|
|
}
|
|
if (payload?.prefs) bootstrapState.prefs = payload.prefs;
|
|
}
|
|
|
|
function repositoryPreferences() {
|
|
bootstrapState.prefs.repositories ??= {
|
|
pinned: null,
|
|
favorites: [],
|
|
recent: [bootstrapState.prefs.repository],
|
|
};
|
|
return bootstrapState.prefs.repositories;
|
|
}
|
|
|
|
function rememberRepositoryClient(repository) {
|
|
const normalized = normalizeRepository(repository);
|
|
const repositories = repositoryPreferences();
|
|
bootstrapState.prefs.repository = normalized;
|
|
repositories.recent = [
|
|
normalized,
|
|
...(repositories.recent ?? []).filter((item) => item !== normalized),
|
|
].slice(0, 12);
|
|
return normalized;
|
|
}
|
|
|
|
function localRepositoryGroups(query) {
|
|
const repositories = repositoryPreferences();
|
|
const needle = String(query ?? "").trim().toLocaleLowerCase();
|
|
const seen = new Set();
|
|
const groups = [];
|
|
const add = (label, values) => {
|
|
const items = [];
|
|
for (const value of values) {
|
|
if (!value || seen.has(value)) continue;
|
|
if (needle && !value.toLocaleLowerCase().includes(needle)) continue;
|
|
seen.add(value);
|
|
items.push({ fullName: value, source: label });
|
|
}
|
|
if (items.length) groups.push({ label, items });
|
|
};
|
|
add("Pinned", [repositories.pinned]);
|
|
add("Favorites", repositories.favorites ?? []);
|
|
add("Recent", repositories.recent ?? []);
|
|
return { groups, seen };
|
|
}
|
|
|
|
function repositoryResultHtml(repository, index) {
|
|
const preferences = repositoryPreferences();
|
|
const pinned = preferences.pinned === repository.fullName;
|
|
const favorite = preferences.favorites?.includes(repository.fullName) === true;
|
|
const favoriteLimitReached = (preferences.favorites?.length ?? 0) >= 3;
|
|
const description = repository.description
|
|
? `<span class="repository-result-description">${escapeHtml(repository.description)}</span>`
|
|
: "";
|
|
const badges = [
|
|
repository.private ? '<span class="Label">Private</span>' : "",
|
|
repository.archived ? '<span class="Label">Archived</span>' : "",
|
|
].join("");
|
|
return `
|
|
<div class="repository-result" data-repository-result="${escapeHtml(repository.fullName)}">
|
|
<button
|
|
class="repository-result-main${index === repositoryActiveIndex ? " is-active" : ""}"
|
|
type="button"
|
|
role="option"
|
|
aria-selected="${index === repositoryActiveIndex ? "true" : "false"}"
|
|
data-select-repository="${escapeHtml(repository.fullName)}"
|
|
data-repository-option-index="${index}"
|
|
>
|
|
${icon("repo")}
|
|
<span class="repository-result-copy">
|
|
<span class="repository-result-name">${escapeHtml(repository.fullName)} ${badges}</span>
|
|
${description}
|
|
</span>
|
|
</button>
|
|
<span class="repository-result-actions">
|
|
<button
|
|
class="repository-result-action${pinned ? " is-selected" : ""}"
|
|
type="button"
|
|
data-pin-repository="${escapeHtml(repository.fullName)}"
|
|
aria-label="${pinned ? "Unpin" : "Pin"} ${escapeHtml(repository.fullName)}"
|
|
aria-pressed="${pinned}"
|
|
title="${pinned ? "Unpin repository" : "Pin repository"}"
|
|
>${icon(pinned ? "pin-slash" : "pin")}</button>
|
|
<button
|
|
class="repository-result-action${favorite ? " is-selected" : ""}"
|
|
type="button"
|
|
data-favorite-repository="${escapeHtml(repository.fullName)}"
|
|
data-favorite="${favorite ? "false" : "true"}"
|
|
aria-label="${favorite ? "Remove" : "Add"} ${escapeHtml(repository.fullName)} ${favorite ? "from" : "to"} favorites"
|
|
aria-pressed="${favorite}"
|
|
title="${favorite ? "Remove from favorites" : "Add to favorites"}"
|
|
${pinned || (!favorite && favoriteLimitReached) ? "disabled" : ""}
|
|
>${icon(favorite ? "star-fill" : "star")}</button>
|
|
</span>
|
|
</div>`;
|
|
}
|
|
|
|
function renderRepositoryPicker() {
|
|
if (repositoryPanel.hidden) return;
|
|
const { groups, seen } = localRepositoryGroups(repositoryPickerQuery);
|
|
const remote = repositoryRemoteResults.filter(
|
|
(item) => item?.fullName && !seen.has(item.fullName),
|
|
);
|
|
if (remote.length) groups.push({ label: "GitHub", items: remote });
|
|
const optionCount = groups.reduce((count, group) => count + group.items.length, 0);
|
|
repositoryActiveIndex =
|
|
optionCount === 0 ? -1 : Math.min(repositoryActiveIndex, optionCount - 1);
|
|
let optionIndex = 0;
|
|
const content = groups
|
|
.map(
|
|
(group) => `
|
|
<div class="repository-result-group">
|
|
<div class="repository-result-heading">${escapeHtml(group.label)}</div>
|
|
${group.items
|
|
.map((repository) => repositoryResultHtml(repository, optionIndex++))
|
|
.join("")}
|
|
</div>`,
|
|
)
|
|
.join("");
|
|
const status =
|
|
repositorySearchState === "loading"
|
|
? `<div class="repository-result-status">${icon("sync", "spin")} Searching GitHub...</div>`
|
|
: repositorySearchError
|
|
? `<div class="repository-result-status error">${escapeHtml(repositorySearchError)}</div>`
|
|
: optionCount === 0
|
|
? '<div class="repository-result-status">No repositories found.</div>'
|
|
: "";
|
|
repositoryResults.innerHTML = content + status;
|
|
}
|
|
|
|
function setRepositoryPickerOpen(open, { resetQuery = false } = {}) {
|
|
if (resetQuery) {
|
|
repositoryPickerQuery = "";
|
|
repositoryRemoteResults = [];
|
|
repositorySearchState = "idle";
|
|
repositorySearchError = null;
|
|
repositoryActiveIndex = -1;
|
|
}
|
|
repositoryPanel.hidden = !open;
|
|
repositoryInput.setAttribute("aria-expanded", String(open));
|
|
repositoryMenuButton.setAttribute("aria-expanded", String(open));
|
|
repositoryCombobox.classList.toggle("is-open", open);
|
|
if (open) renderRepositoryPicker();
|
|
}
|
|
|
|
function scheduleRepositorySearch(value) {
|
|
repositoryPickerQuery = String(value ?? "").trim();
|
|
repositorySearchError = null;
|
|
repositoryRemoteResults = [];
|
|
repositoryActiveIndex = -1;
|
|
window.clearTimeout(repositorySearchTimer);
|
|
repositorySearchController?.abort();
|
|
const shouldSearch =
|
|
repositoryPickerQuery.includes("/") || repositoryPickerQuery.length >= 2;
|
|
repositorySearchState = shouldSearch ? "loading" : "idle";
|
|
setRepositoryPickerOpen(true);
|
|
renderRepositoryPicker();
|
|
if (!shouldSearch) return;
|
|
|
|
const sequence = ++repositorySearchSequence;
|
|
repositorySearchTimer = window.setTimeout(async () => {
|
|
const controller = new AbortController();
|
|
repositorySearchController = controller;
|
|
try {
|
|
const payload = await api(
|
|
`/api/repositories/suggest?q=${encodeURIComponent(repositoryPickerQuery)}`,
|
|
{ signal: controller.signal },
|
|
);
|
|
if (sequence !== repositorySearchSequence) return;
|
|
repositoryRemoteResults = payload.repositories ?? [];
|
|
repositorySearchState = "ready";
|
|
} catch (error) {
|
|
if (controller.signal.aborted || sequence !== repositorySearchSequence) return;
|
|
repositorySearchState = "error";
|
|
repositorySearchError = error instanceof Error ? error.message : String(error);
|
|
} finally {
|
|
if (sequence === repositorySearchSequence) renderRepositoryPicker();
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
function moveRepositorySelection(delta) {
|
|
const options = [...repositoryResults.querySelectorAll("[data-repository-option-index]")];
|
|
if (!options.length) return;
|
|
repositoryActiveIndex =
|
|
repositoryActiveIndex < 0
|
|
? delta > 0
|
|
? 0
|
|
: options.length - 1
|
|
: (repositoryActiveIndex + delta + options.length) % options.length;
|
|
for (const option of options) {
|
|
const active = Number(option.dataset.repositoryOptionIndex) === repositoryActiveIndex;
|
|
option.classList.toggle("is-active", active);
|
|
option.setAttribute("aria-selected", String(active));
|
|
if (active) option.scrollIntoView({ block: "nearest" });
|
|
}
|
|
}
|
|
|
|
async function setPinnedRepository(repository) {
|
|
const current = repositoryPreferences().pinned;
|
|
const prefs = await api("/api/repositories/pin", {
|
|
method: "POST",
|
|
body: { repository: current === repository ? null : repository },
|
|
});
|
|
bootstrapState.prefs = prefs;
|
|
renderRepositoryPicker();
|
|
showToast(current === repository ? "Repository unpinned." : `${repository} pinned.`);
|
|
}
|
|
|
|
async function setFavoriteRepository(repository, favorite) {
|
|
const prefs = await api("/api/repositories/favorite", {
|
|
method: "POST",
|
|
body: { repository, favorite },
|
|
});
|
|
bootstrapState.prefs = prefs;
|
|
renderRepositoryPicker();
|
|
showToast(favorite ? `${repository} added to favorites.` : `${repository} removed from favorites.`);
|
|
}
|
|
|
|
function mixRgb(from, to, amount) {
|
|
return from.map((value, index) =>
|
|
Math.round(value + (to[index] - value) * amount),
|
|
);
|
|
}
|
|
|
|
function relativeLuminance(rgb) {
|
|
const channels = rgb.map((value) => {
|
|
const channel = value / 255;
|
|
return channel <= 0.04045
|
|
? channel / 12.92
|
|
: ((channel + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
|
}
|
|
|
|
function contrastRatio(first, second) {
|
|
const luminances = [relativeLuminance(first), relativeLuminance(second)].sort(
|
|
(left, right) => right - left,
|
|
);
|
|
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
|
|
}
|
|
|
|
function accessibleLabelText(base, background, target) {
|
|
const labelBackground = mixRgb(background, base, 0.18);
|
|
for (let step = 0; step <= 100; step++) {
|
|
const candidate = mixRgb(base, target, step / 100);
|
|
if (contrastRatio(candidate, labelBackground) >= 4.6) return candidate;
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function labelHtml(label) {
|
|
if (!/^[0-9a-f]{6}$/i.test(label.color ?? "")) {
|
|
return `<span class="issue-label">${escapeHtml(label.name)}</span>`;
|
|
}
|
|
|
|
const hex = label.color;
|
|
const base = [0, 2, 4].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
|
|
const lightText = accessibleLabelText(base, [255, 255, 255], [31, 35, 40]);
|
|
const darkText = accessibleLabelText(base, [13, 17, 23], [240, 246, 252]);
|
|
return `<span class="issue-label" style="--label-color:#${hex};--label-text-light:rgb(${lightText.join(" ")});--label-text-dark:rgb(${darkText.join(" ")})">${escapeHtml(label.name)}</span>`;
|
|
}
|
|
|
|
function pullState(pull) {
|
|
if (pull.mergedAt) return { label: "Merged", className: "merged" };
|
|
if (pull.draft) return { label: "Draft", className: "draft" };
|
|
if (pull.state === "closed") return { label: "Closed", className: "closed" };
|
|
return { label: "Open", className: "open" };
|
|
}
|
|
|
|
function pullIconClass(pull) {
|
|
return pullState(pull).className;
|
|
}
|
|
|
|
function pullCheckHtml(pull) {
|
|
const states = {
|
|
passing: ["check", "passing", "Checks passing"],
|
|
failing: ["x", "failing", "Checks failing"],
|
|
pending: ["clock", "pending", "Checks pending"],
|
|
};
|
|
const state = states[pull.ciStatus];
|
|
if (!state) return "";
|
|
return `<span class="pull-check ${state[1]}" role="img" aria-label="${state[2]}" title="${state[2]}">${icon(state[0])}</span>`;
|
|
}
|
|
|
|
function pullAuthorBadge(pull) {
|
|
if (pull.authorType === "Bot" || /\[bot\]$/i.test(pull.author ?? "")) return "Bot";
|
|
if (!["MEMBER", "OWNER", "COLLABORATOR"].includes(pull.authorAssociation)) return "";
|
|
return (
|
|
pull.authorAssociation[0] + pull.authorAssociation.slice(1).toLocaleLowerCase()
|
|
);
|
|
}
|
|
|
|
function pullReviewStatus(pull) {
|
|
if (pull.draft) return "Draft";
|
|
if (pull.state !== "open") return "";
|
|
return (
|
|
{
|
|
REVIEW_REQUIRED: "Review required",
|
|
CHANGES_REQUESTED: "Changes requested",
|
|
APPROVED: "Approved",
|
|
}[pull.reviewDecision] ?? ""
|
|
);
|
|
}
|
|
|
|
function pullQueryForState(value, state) {
|
|
let query = String(value ?? "")
|
|
.replace(/(^|\s)is:(?:open|closed|merged|unmerged)\b/gi, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
if (!/\bis:pr\b/i.test(query)) query = `is:pr ${query}`.trim();
|
|
if (state === "open" || state === "closed") query = `${query} is:${state}`;
|
|
return query;
|
|
}
|
|
|
|
function normalizePullQuery(value, state) {
|
|
let query = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
if (!/\bis:pr\b/i.test(query)) query = `is:pr ${query}`.trim();
|
|
if (
|
|
(state === "open" || state === "closed") &&
|
|
!/\bis:(?:open|closed|merged|unmerged)\b/i.test(query)
|
|
) {
|
|
query = `${query} is:${state}`;
|
|
}
|
|
return query;
|
|
}
|
|
|
|
function stateFromPullQuery(value) {
|
|
const query = String(value ?? "");
|
|
if (/\bis:(?:closed|merged)\b/i.test(query)) return "closed";
|
|
if (/\bis:open\b/i.test(query)) return "open";
|
|
return "all";
|
|
}
|
|
|
|
function pullListMeta(pull) {
|
|
const status = pullState(pull);
|
|
const timestamp =
|
|
status.className === "merged"
|
|
? pull.mergedAt
|
|
: status.className === "closed"
|
|
? pull.closedAt
|
|
: pull.createdAt;
|
|
const verb =
|
|
status.className === "merged"
|
|
? "merged"
|
|
: status.className === "closed"
|
|
? "closed"
|
|
: "opened";
|
|
return `#${pull.number} ${verb} ${relativeTime(timestamp)} by ${pull.author}`;
|
|
}
|
|
|
|
function pullStatePanelHtml(state) {
|
|
const options = [
|
|
["open", "Open", "Show pull requests that are open"],
|
|
["closed", "Closed", "Show pull requests that are closed or merged"],
|
|
["all", "All", "Show pull requests in any state"],
|
|
];
|
|
return `
|
|
<div class="pull-state-picker">
|
|
<button
|
|
class="pull-state-trigger"
|
|
type="button"
|
|
data-toggle-pull-state
|
|
aria-haspopup="menu"
|
|
aria-expanded="false"
|
|
aria-controls="pull-state-panel"
|
|
>
|
|
${escapeHtml(state[0].toUpperCase() + state.slice(1))}
|
|
${icon("chevron-down")}
|
|
</button>
|
|
<div id="pull-state-panel" class="pull-state-panel" role="menu" hidden>
|
|
<div class="pull-state-panel-header">
|
|
<strong>Filter by pull request state</strong>
|
|
<button class="pull-state-close" type="button" data-close-pull-state aria-label="Close state filter">
|
|
${icon("x")}
|
|
</button>
|
|
</div>
|
|
<div class="pull-state-options">
|
|
${options
|
|
.map(
|
|
([value, label, description]) => `
|
|
<button
|
|
class="pull-state-option"
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked="${state === value}"
|
|
data-select-pull-state="${value}"
|
|
>
|
|
<span class="pull-state-check">${state === value ? icon("check") : ""}</span>
|
|
<span>
|
|
<strong>${label}</strong>
|
|
<small>${description}</small>
|
|
</span>
|
|
</button>`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function setPullStatePanelOpen(open) {
|
|
const panel = document.getElementById("pull-state-panel");
|
|
const trigger = document.querySelector("[data-toggle-pull-state]");
|
|
if (!panel || !trigger) return;
|
|
panel.hidden = !open;
|
|
trigger.setAttribute("aria-expanded", String(open));
|
|
trigger.closest(".pull-state-picker")?.classList.toggle("is-open", open);
|
|
}
|
|
|
|
const PULL_SIGNAL_FILTERS = {
|
|
artifacts: {
|
|
label: "Artifacts",
|
|
heading: "Filter by Actions artifacts",
|
|
options: [
|
|
["all", "Any", "Any"],
|
|
["with", "With artifacts", "Present"],
|
|
["without", "Without artifacts", "None"],
|
|
],
|
|
},
|
|
ci: {
|
|
label: "Checks",
|
|
heading: "Filter by check status",
|
|
options: [
|
|
["all", "Any status", "Any"],
|
|
["failing", "Failing", "Failing"],
|
|
["passing", "Passing", "Passing"],
|
|
["pending", "Pending", "Pending"],
|
|
["none", "No checks", "None"],
|
|
],
|
|
},
|
|
};
|
|
|
|
function pullSignalPanelHtml(kind, selected) {
|
|
const config = PULL_SIGNAL_FILTERS[kind];
|
|
const selectedOption =
|
|
config.options.find(([value]) => value === selected) ?? config.options[0];
|
|
const filtered = selectedOption[0] !== "all";
|
|
return `
|
|
<div class="pull-signal-picker">
|
|
<button
|
|
class="pull-state-trigger${filtered ? " is-filtered" : ""}"
|
|
type="button"
|
|
data-toggle-pull-signal="${kind}"
|
|
aria-haspopup="menu"
|
|
aria-expanded="false"
|
|
aria-controls="pull-${kind}-panel"
|
|
title="${filtered ? `${escapeHtml(config.label)}: ${escapeHtml(selectedOption[1])}` : escapeHtml(config.heading)}"
|
|
>
|
|
<span>${escapeHtml(config.label)}${filtered ? ":" : ""}</span>
|
|
${filtered ? `<span class="pull-filter-value">${escapeHtml(selectedOption[2])}</span>` : ""}
|
|
${icon("chevron-down")}
|
|
</button>
|
|
<div id="pull-${kind}-panel" class="pull-state-panel" role="menu" hidden>
|
|
<div class="pull-state-panel-header">
|
|
<strong>${escapeHtml(config.heading)}</strong>
|
|
<button
|
|
class="pull-state-close"
|
|
type="button"
|
|
data-close-pull-signal="${kind}"
|
|
aria-label="Close ${escapeHtml(config.label.toLocaleLowerCase())} filter"
|
|
>
|
|
${icon("x")}
|
|
</button>
|
|
</div>
|
|
<div class="pull-state-options">
|
|
${config.options
|
|
.map(
|
|
([value, label]) => `
|
|
<button
|
|
class="pull-state-option pull-signal-option"
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked="${selectedOption[0] === value}"
|
|
data-select-pull-signal="${kind}"
|
|
data-pull-signal-value="${value}"
|
|
>
|
|
<span class="pull-state-check">${selectedOption[0] === value ? icon("check") : ""}</span>
|
|
<span>
|
|
<strong>${escapeHtml(label)}</strong>
|
|
</span>
|
|
</button>`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function setPullSignalPanelOpen(kind, open) {
|
|
for (const candidate of Object.keys(PULL_SIGNAL_FILTERS)) {
|
|
const panel = document.getElementById(`pull-${candidate}-panel`);
|
|
const trigger = document.querySelector(
|
|
`[data-toggle-pull-signal="${candidate}"]`,
|
|
);
|
|
const isOpen = candidate === kind && open;
|
|
if (panel) panel.hidden = !isOpen;
|
|
if (trigger) trigger.setAttribute("aria-expanded", String(isOpen));
|
|
trigger?.closest(".pull-signal-picker")?.classList.toggle("is-open", isOpen);
|
|
}
|
|
}
|
|
|
|
function authorFromPullQuery(value) {
|
|
const match = String(value ?? "").match(
|
|
/(?:^|\s)author:(?:"([^"]+)"|([^\s]+))/i,
|
|
);
|
|
return match?.[1] ?? match?.[2] ?? null;
|
|
}
|
|
|
|
function pullQueryForAuthor(value, author) {
|
|
let query = String(value ?? "")
|
|
.replace(/(^|\s)author:(?:"[^"]+"|[^\s]+)/gi, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
if (author) query = `${query} author:${author}`.trim();
|
|
return query;
|
|
}
|
|
|
|
function pullQueryHighlightHtml(value) {
|
|
const source = String(value ?? "");
|
|
const matcher = /(^|\s)(-?)([a-z][\w-]*)(:)((?:"[^"]*"|[^\s]*))/gi;
|
|
let highlighted = "";
|
|
let cursor = 0;
|
|
for (const match of source.matchAll(matcher)) {
|
|
highlighted += escapeHtml(source.slice(cursor, match.index));
|
|
highlighted += escapeHtml(match[1]);
|
|
if (match[2]) {
|
|
highlighted += `<span class="pull-query-syntax-negation">${escapeHtml(match[2])}</span>`;
|
|
}
|
|
highlighted += `<span class="pull-query-syntax-qualifier">${escapeHtml(match[3])}</span>`;
|
|
highlighted += `<span class="pull-query-syntax-punctuation">${escapeHtml(match[4])}</span>`;
|
|
if (match[5]) {
|
|
highlighted += `<span class="pull-query-syntax-value">${escapeHtml(match[5])}</span>`;
|
|
}
|
|
cursor = match.index + match[0].length;
|
|
}
|
|
highlighted += escapeHtml(source.slice(cursor));
|
|
return highlighted || " ";
|
|
}
|
|
|
|
function pullQueryTokenRange(value, caret) {
|
|
const source = String(value ?? "");
|
|
const position = Math.max(0, Math.min(Number(caret) || 0, source.length));
|
|
let quoted = false;
|
|
let start = 0;
|
|
for (let index = 0; index < position; index++) {
|
|
if (source[index] === '"') quoted = !quoted;
|
|
if (!quoted && /\s/.test(source[index])) start = index + 1;
|
|
}
|
|
let end = source.length;
|
|
for (let index = position; index < source.length; index++) {
|
|
if (source[index] === '"') quoted = !quoted;
|
|
if (!quoted && /\s/.test(source[index])) {
|
|
end = index;
|
|
break;
|
|
}
|
|
}
|
|
return {
|
|
start,
|
|
end,
|
|
prefix: source.slice(start, position),
|
|
};
|
|
}
|
|
|
|
function pullQueryQualifierValues(qualifier) {
|
|
if (qualifier.values) return qualifier.values;
|
|
if (qualifier.dynamicValues === "authors") {
|
|
return pullAuthors.slice(0, 40).map((author) => [
|
|
author.login,
|
|
author.name && author.name !== author.login
|
|
? author.name
|
|
: "Repository contributor",
|
|
]);
|
|
}
|
|
if (qualifier.dynamicValues === "labels") {
|
|
return pullQueryLabels.slice(0, 40).map((label) => [label, "Repository label"]);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function pullQueryValueLiteral(value) {
|
|
const source = String(value ?? "");
|
|
return /\s/.test(source) ? `"${source.replaceAll('"', '\\"')}"` : source;
|
|
}
|
|
|
|
function pullQueryCompletions(input) {
|
|
const range = pullQueryTokenRange(input.value, input.selectionStart);
|
|
if (!range.prefix) return { ...range, heading: "", items: [] };
|
|
const token = range.prefix.match(/^(-?)([a-z][\w-]*)(?::(.*))?$/i);
|
|
if (!token) return { ...range, heading: "", items: [] };
|
|
const negation = token[1] ?? "";
|
|
const qualifierName = token[2].toLocaleLowerCase();
|
|
const hasValue = range.prefix.includes(":");
|
|
|
|
if (!hasValue) {
|
|
const items = PULL_QUERY_QUALIFIERS
|
|
.filter((qualifier) => qualifier.name.startsWith(qualifierName))
|
|
.slice(0, 8)
|
|
.map((qualifier) => ({
|
|
complete: false,
|
|
description: qualifier.description,
|
|
insert: `${negation}${qualifier.name}:`,
|
|
label: `${qualifier.name}:`,
|
|
}));
|
|
return { ...range, heading: "Qualifiers", items };
|
|
}
|
|
|
|
const qualifier = PULL_QUERY_QUALIFIERS.find(
|
|
(candidate) => candidate.name === qualifierName,
|
|
);
|
|
if (!qualifier) return { ...range, heading: "", items: [] };
|
|
const valuePrefix = String(token[3] ?? "")
|
|
.replace(/^"/, "")
|
|
.toLocaleLowerCase();
|
|
const items = pullQueryQualifierValues(qualifier)
|
|
.filter(([value]) => String(value).toLocaleLowerCase().startsWith(valuePrefix))
|
|
.slice(0, 8)
|
|
.map(([value, description]) => ({
|
|
complete: true,
|
|
description,
|
|
insert: `${negation}${qualifier.name}:${pullQueryValueLiteral(value)}`,
|
|
label: `${qualifier.name}:${pullQueryValueLiteral(value)}`,
|
|
}));
|
|
return {
|
|
...range,
|
|
heading: `Values for ${qualifier.name}:`,
|
|
items,
|
|
};
|
|
}
|
|
|
|
function setPullQuerySuggestionsOpen(input, open) {
|
|
const suggestions = document.getElementById("pull-query-suggestions");
|
|
if (!suggestions) return;
|
|
suggestions.hidden = !open;
|
|
input?.setAttribute("aria-expanded", String(open));
|
|
if (!open) {
|
|
pullQuerySuggestionIndex = -1;
|
|
input?.removeAttribute("aria-activedescendant");
|
|
}
|
|
}
|
|
|
|
function syncPullQueryEditor(input) {
|
|
const highlight = document.getElementById("pull-query-highlight-content");
|
|
if (!highlight || !input) return;
|
|
highlight.innerHTML = pullQueryHighlightHtml(input.value);
|
|
highlight.style.transform = `translateX(${-input.scrollLeft}px)`;
|
|
}
|
|
|
|
function renderPullQuerySuggestions(input, { preserveIndex = false } = {}) {
|
|
const suggestions = document.getElementById("pull-query-suggestions");
|
|
if (!suggestions || !input || document.activeElement !== input) {
|
|
setPullQuerySuggestionsOpen(input, false);
|
|
return;
|
|
}
|
|
const completion = pullQueryCompletions(input);
|
|
if (!completion.items.length) {
|
|
setPullQuerySuggestionsOpen(input, false);
|
|
return;
|
|
}
|
|
pullQuerySuggestionIndex = preserveIndex
|
|
? Math.max(
|
|
0,
|
|
Math.min(pullQuerySuggestionIndex, completion.items.length - 1),
|
|
)
|
|
: 0;
|
|
suggestions.innerHTML = `
|
|
<div class="pull-query-suggestion-heading">${escapeHtml(completion.heading)}</div>
|
|
<div class="pull-query-suggestion-options">
|
|
${completion.items
|
|
.map(
|
|
(item, index) => `
|
|
<button
|
|
id="pull-query-suggestion-${index}"
|
|
class="pull-query-suggestion${index === pullQuerySuggestionIndex ? " is-active" : ""}"
|
|
type="button"
|
|
role="option"
|
|
aria-selected="${index === pullQuerySuggestionIndex}"
|
|
data-pull-query-suggestion="${index}"
|
|
>
|
|
<code>${pullQueryHighlightHtml(item.label)}</code>
|
|
<span>${escapeHtml(item.description)}</span>
|
|
</button>`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
<div class="pull-query-suggestion-footer">
|
|
<span><kbd>Tab</kbd> insert</span>
|
|
<span><kbd>Enter</kbd> apply</span>
|
|
</div>`;
|
|
setPullQuerySuggestionsOpen(input, true);
|
|
input.setAttribute(
|
|
"aria-activedescendant",
|
|
`pull-query-suggestion-${pullQuerySuggestionIndex}`,
|
|
);
|
|
}
|
|
|
|
function movePullQuerySuggestion(input, delta) {
|
|
const options = [
|
|
...document.querySelectorAll("[data-pull-query-suggestion]"),
|
|
];
|
|
if (!options.length) return false;
|
|
pullQuerySuggestionIndex =
|
|
(pullQuerySuggestionIndex + delta + options.length) % options.length;
|
|
for (const option of options) {
|
|
const active =
|
|
Number(option.dataset.pullQuerySuggestion) === pullQuerySuggestionIndex;
|
|
option.classList.toggle("is-active", active);
|
|
option.setAttribute("aria-selected", String(active));
|
|
if (active) option.scrollIntoView({ block: "nearest" });
|
|
}
|
|
input.setAttribute(
|
|
"aria-activedescendant",
|
|
`pull-query-suggestion-${pullQuerySuggestionIndex}`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
function insertPullQuerySuggestion(input, index = pullQuerySuggestionIndex) {
|
|
const completion = pullQueryCompletions(input);
|
|
const suggestion = completion.items[index];
|
|
if (!suggestion) return false;
|
|
const after = input.value.slice(completion.end);
|
|
const separator = suggestion.complete && !/^\s/.test(after) ? " " : "";
|
|
input.value =
|
|
input.value.slice(0, completion.start) +
|
|
suggestion.insert +
|
|
separator +
|
|
after;
|
|
const caret = completion.start + suggestion.insert.length + separator.length;
|
|
input.setSelectionRange(caret, caret);
|
|
input.focus();
|
|
syncPullQueryEditor(input);
|
|
renderPullQuerySuggestions(input);
|
|
return true;
|
|
}
|
|
|
|
async function commitPullQuery(input) {
|
|
const route = parseRoute();
|
|
if (!input || route.name !== "pulls") return;
|
|
setPullQuerySuggestionsOpen(input, false);
|
|
const state = stateFromPullQuery(input.value);
|
|
const query = normalizePullQuery(input.value, state);
|
|
const currentQuery = normalizePullQuery(route.query, route.state);
|
|
if (state === route.state && query === currentQuery) return;
|
|
const commitKey = JSON.stringify([
|
|
route.repository,
|
|
state,
|
|
query,
|
|
route.artifacts,
|
|
route.ci,
|
|
]);
|
|
if (pullQueryPendingCommit === commitKey) return;
|
|
pullQueryPendingCommit = commitKey;
|
|
try {
|
|
await refreshPullTable({
|
|
name: "pulls",
|
|
repository: route.repository,
|
|
state,
|
|
query,
|
|
artifacts: route.artifacts,
|
|
ci: route.ci,
|
|
});
|
|
} finally {
|
|
if (pullQueryPendingCommit === commitKey) pullQueryPendingCommit = null;
|
|
}
|
|
}
|
|
|
|
function seedPullAuthors(repository, pulls) {
|
|
const seeded = [];
|
|
const seen = new Set();
|
|
const repositoryChanged = pullAuthorsRepository !== repository;
|
|
const labels = new Map(
|
|
(repositoryChanged ? [] : pullQueryLabels).map((label) => [
|
|
label.toLocaleLowerCase(),
|
|
label,
|
|
]),
|
|
);
|
|
for (const pull of pulls) {
|
|
for (const label of pull.labels ?? []) {
|
|
if (!label?.name) continue;
|
|
labels.set(label.name.toLocaleLowerCase(), label.name);
|
|
}
|
|
const login = pull.author;
|
|
if (!login || seen.has(login.toLocaleLowerCase())) continue;
|
|
seen.add(login.toLocaleLowerCase());
|
|
seeded.push({
|
|
login,
|
|
name: null,
|
|
avatarUrl: pull.authorAvatarUrl ?? null,
|
|
profileUrl: null,
|
|
type: "User",
|
|
contributions: 0,
|
|
});
|
|
}
|
|
pullQueryLabels = [...labels.values()].sort((left, right) =>
|
|
left.localeCompare(right),
|
|
);
|
|
if (repositoryChanged) {
|
|
pullAuthorsLoadSequence++;
|
|
pullAuthorsRepository = repository;
|
|
pullAuthors = seeded;
|
|
pullAuthorsState = "idle";
|
|
pullAuthorsError = null;
|
|
return;
|
|
}
|
|
for (const author of seeded) {
|
|
if (!pullAuthors.some((item) => item.login.toLocaleLowerCase() === author.login.toLocaleLowerCase())) {
|
|
pullAuthors.push(author);
|
|
}
|
|
}
|
|
}
|
|
|
|
function pullAuthorOptionsHtml(filter = "", queryOverride = null) {
|
|
const query =
|
|
queryOverride ?? document.getElementById("pull-query")?.value ?? "";
|
|
const selected = authorFromPullQuery(query);
|
|
const needle = String(filter ?? "").trim().toLocaleLowerCase();
|
|
const authors = pullAuthors.filter((author) =>
|
|
`${author.login} ${author.name ?? ""}`.toLocaleLowerCase().includes(needle),
|
|
);
|
|
const everyoneMatches = !needle || "everyone".includes(needle);
|
|
const options = [
|
|
everyoneMatches
|
|
? `<button
|
|
class="pull-author-option"
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked="${selected == null}"
|
|
data-select-pull-author=""
|
|
>
|
|
<span class="pull-author-check">${selected == null ? icon("check") : ""}</span>
|
|
<span class="pull-author-avatar pull-author-avatar-fallback">${icon("person")}</span>
|
|
<span class="pull-author-identity"><strong>Everyone</strong></span>
|
|
</button>`
|
|
: "",
|
|
...authors.map(
|
|
(author) => `
|
|
<button
|
|
class="pull-author-option"
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked="${selected?.toLocaleLowerCase() === author.login.toLocaleLowerCase()}"
|
|
data-select-pull-author="${escapeHtml(author.login)}"
|
|
>
|
|
<span class="pull-author-check">${
|
|
selected?.toLocaleLowerCase() === author.login.toLocaleLowerCase()
|
|
? icon("check")
|
|
: ""
|
|
}</span>
|
|
${
|
|
author.avatarUrl
|
|
? `<img class="pull-author-avatar" src="${escapeHtml(author.avatarUrl)}" alt="" />`
|
|
: `<span class="pull-author-avatar pull-author-avatar-fallback">${icon("person")}</span>`
|
|
}
|
|
<span class="pull-author-identity">
|
|
<strong>${escapeHtml(author.login)}</strong>
|
|
${
|
|
author.name &&
|
|
author.name.toLocaleLowerCase() !== author.login.toLocaleLowerCase()
|
|
? `<span class="pull-author-name">${escapeHtml(author.name)}</span>`
|
|
: ""
|
|
}
|
|
</span>
|
|
</button>`,
|
|
),
|
|
].join("");
|
|
const status =
|
|
pullAuthorsState === "loading"
|
|
? `<div class="pull-author-status">${icon("sync", "spin")} Loading authors...</div>`
|
|
: pullAuthorsError
|
|
? `<div class="pull-author-status error">${escapeHtml(pullAuthorsError)}</div>`
|
|
: !options
|
|
? '<div class="pull-author-status">No authors match.</div>'
|
|
: "";
|
|
return options + status;
|
|
}
|
|
|
|
function renderPullAuthorOptions(filter = "") {
|
|
const list = document.getElementById("pull-author-options");
|
|
if (list) list.innerHTML = pullAuthorOptionsHtml(filter);
|
|
}
|
|
|
|
async function loadPullAuthors(repository) {
|
|
if (
|
|
pullAuthorsRepository === repository &&
|
|
(pullAuthorsState === "loading" || pullAuthorsState === "ready")
|
|
) {
|
|
return;
|
|
}
|
|
pullAuthorsRepository = repository;
|
|
pullAuthorsState = "loading";
|
|
pullAuthorsError = null;
|
|
const sequence = ++pullAuthorsLoadSequence;
|
|
renderPullAuthorOptions(document.getElementById("pull-author-filter")?.value);
|
|
try {
|
|
const payload = await api(
|
|
`/api/repositories/authors?repo=${encodeURIComponent(repository)}`,
|
|
);
|
|
if (
|
|
sequence !== pullAuthorsLoadSequence ||
|
|
pullAuthorsRepository !== repository
|
|
) {
|
|
return;
|
|
}
|
|
const byLogin = new Map(
|
|
pullAuthors.map((author) => [author.login.toLocaleLowerCase(), author]),
|
|
);
|
|
for (const author of payload.authors ?? []) {
|
|
byLogin.set(author.login.toLocaleLowerCase(), author);
|
|
}
|
|
pullAuthors = [...byLogin.values()];
|
|
pullAuthorsState = "ready";
|
|
} catch (error) {
|
|
if (
|
|
sequence !== pullAuthorsLoadSequence ||
|
|
pullAuthorsRepository !== repository
|
|
) {
|
|
return;
|
|
}
|
|
pullAuthorsState = "error";
|
|
pullAuthorsError = error instanceof Error ? error.message : String(error);
|
|
}
|
|
if (
|
|
sequence === pullAuthorsLoadSequence &&
|
|
pullAuthorsRepository === repository
|
|
) {
|
|
renderPullAuthorOptions(document.getElementById("pull-author-filter")?.value);
|
|
}
|
|
}
|
|
|
|
function pullAuthorPanelHtml(query) {
|
|
const selected = authorFromPullQuery(query);
|
|
return `
|
|
<div class="pull-author-picker">
|
|
<button
|
|
class="pull-state-trigger${selected ? " is-filtered" : ""}"
|
|
type="button"
|
|
data-toggle-pull-author
|
|
aria-haspopup="menu"
|
|
aria-expanded="false"
|
|
aria-controls="pull-author-panel"
|
|
title="${selected ? `Author: ${escapeHtml(selected)}` : "Filter by author"}"
|
|
>
|
|
<span>Author${selected ? ":" : ""}</span>
|
|
${selected ? `<span class="pull-filter-value">${escapeHtml(selected)}</span>` : ""}
|
|
${icon("chevron-down")}
|
|
</button>
|
|
<div id="pull-author-panel" class="pull-author-panel" role="menu" hidden>
|
|
<div class="pull-state-panel-header">
|
|
<strong>Filter by author</strong>
|
|
<button class="pull-state-close" type="button" data-close-pull-author aria-label="Close author filter">
|
|
${icon("x")}
|
|
</button>
|
|
</div>
|
|
<div class="pull-author-filter-wrap">
|
|
${icon("search")}
|
|
<label class="sr-only" for="pull-author-filter">Filter authors</label>
|
|
<input id="pull-author-filter" class="form-control" type="search" placeholder="Filter users" autocomplete="off" />
|
|
</div>
|
|
<div id="pull-author-options" class="pull-author-options">${pullAuthorOptionsHtml("", query)}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function setPullAuthorPanelOpen(open) {
|
|
const panel = document.getElementById("pull-author-panel");
|
|
const trigger = document.querySelector("[data-toggle-pull-author]");
|
|
if (!panel || !trigger) return;
|
|
panel.hidden = !open;
|
|
trigger.setAttribute("aria-expanded", String(open));
|
|
trigger.closest(".pull-author-picker")?.classList.toggle("is-open", open);
|
|
if (open) {
|
|
document.getElementById("pull-author-filter")?.focus();
|
|
void loadPullAuthors(parseRoute().repository);
|
|
}
|
|
}
|
|
|
|
function artifactPrimaryLabel(cacheItem) {
|
|
return cacheItem?.primary?.label ?? null;
|
|
}
|
|
|
|
function runStatusIcon(run) {
|
|
if (run.conclusion === "success") {
|
|
return icon("check-circle", "status-success");
|
|
}
|
|
if (["failure", "cancelled", "timed_out", "action_required"].includes(run.conclusion)) {
|
|
return icon("x-circle", "status-danger");
|
|
}
|
|
return icon("clock", "status-attention");
|
|
}
|
|
|
|
const ARTIFACT_STATUS_OPTIONS = [
|
|
{ value: "all", label: "All statuses", icon: "workflow", className: "" },
|
|
{ value: "passing", label: "Passing", icon: "check-circle", className: "status-success" },
|
|
{ value: "failing", label: "Failing", icon: "x-circle", className: "status-danger" },
|
|
{ value: "active", label: "Active", icon: "sync", className: "status-attention" },
|
|
{ value: "cancelled", label: "Cancelled", icon: "x-circle", className: "status-muted" },
|
|
{ value: "skipped", label: "Skipped or neutral", icon: "clock", className: "status-muted" },
|
|
];
|
|
|
|
function artifactStatus(artifact) {
|
|
const status = String(artifact.run?.status ?? "").toLocaleLowerCase();
|
|
const conclusion = String(artifact.run?.conclusion ?? "").toLocaleLowerCase();
|
|
if (status && status !== "completed") return "active";
|
|
if (!conclusion && status !== "completed") return "active";
|
|
if (conclusion === "success") return "passing";
|
|
if (["failure", "timed_out", "action_required", "startup_failure"].includes(conclusion)) {
|
|
return "failing";
|
|
}
|
|
if (conclusion === "cancelled") return "cancelled";
|
|
if (["skipped", "neutral", "stale"].includes(conclusion)) return "skipped";
|
|
return "skipped";
|
|
}
|
|
|
|
function artifactStatusOptions(artifacts) {
|
|
const counts = new Map(ARTIFACT_STATUS_OPTIONS.map((option) => [option.value, 0]));
|
|
counts.set("all", artifacts.length);
|
|
for (const artifact of artifacts) {
|
|
const value = artifactStatus(artifact);
|
|
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
}
|
|
return ARTIFACT_STATUS_OPTIONS.map((option) => ({
|
|
...option,
|
|
count: counts.get(option.value) ?? 0,
|
|
}));
|
|
}
|
|
|
|
function artifactNameKey(artifact) {
|
|
return String(artifact.name ?? "").trim().toLocaleLowerCase();
|
|
}
|
|
|
|
function latestArtifactsByName(artifacts) {
|
|
const seen = new Set();
|
|
return [...artifacts]
|
|
.sort((left, right) => {
|
|
const byDate = new Date(right.createdAt) - new Date(left.createdAt);
|
|
return byDate || Number(right.id) - Number(left.id);
|
|
})
|
|
.filter((artifact) => {
|
|
const key = artifactNameKey(artifact);
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function runEventLabel(value) {
|
|
const event = String(value ?? "");
|
|
const labels = {
|
|
pull_request: "Pull request",
|
|
push: "Push",
|
|
workflow_dispatch: "Manual",
|
|
};
|
|
return labels[event] ?? event.replaceAll("_", " ");
|
|
}
|
|
|
|
function artifactRunLabel(run) {
|
|
const name = String(run?.name ?? "Unknown workflow");
|
|
const number = Number(run?.number);
|
|
const event = runEventLabel(run?.event);
|
|
return `${name}${Number.isFinite(number) ? ` #${number}` : ""}${event ? ` · ${event}` : ""}`;
|
|
}
|
|
|
|
function artifactRunOptions(artifacts, runsTruncated = false) {
|
|
const runs = new Map();
|
|
for (const artifact of artifacts) {
|
|
const id = artifact.run?.id == null ? "unknown" : String(artifact.run.id);
|
|
const value = `run:${id}`;
|
|
const existing = runs.get(value);
|
|
if (existing) {
|
|
existing.count++;
|
|
continue;
|
|
}
|
|
runs.set(value, {
|
|
value,
|
|
label: artifactRunLabel(artifact.run),
|
|
count: 1,
|
|
createdAt: artifact.run?.createdAt ?? artifact.createdAt,
|
|
icon: "workflow",
|
|
});
|
|
}
|
|
return [
|
|
{
|
|
value: "latest",
|
|
label: "Latest artifacts",
|
|
count: latestArtifactsByName(artifacts).length,
|
|
icon: "package",
|
|
},
|
|
{
|
|
value: "all",
|
|
label: runsTruncated ? "All loaded runs" : "All runs",
|
|
count: artifacts.length,
|
|
icon: "workflow",
|
|
},
|
|
...[...runs.values()].sort(
|
|
(left, right) => new Date(right.createdAt) - new Date(left.createdAt),
|
|
),
|
|
];
|
|
}
|
|
|
|
function artifactsForRunFilter(artifacts, selectedRun) {
|
|
if (selectedRun === "all") return artifacts;
|
|
if (String(selectedRun).startsWith("run:")) {
|
|
const runId = String(selectedRun).slice(4);
|
|
return artifacts.filter((artifact) =>
|
|
String(artifact.run?.id ?? "unknown") === runId);
|
|
}
|
|
return latestArtifactsByName(artifacts);
|
|
}
|
|
|
|
function artifactScopeCountLabel(
|
|
allArtifacts,
|
|
scopedArtifacts,
|
|
selectedRun,
|
|
artifactsTruncated = false,
|
|
) {
|
|
const hiddenCopies = allArtifacts.length - scopedArtifacts.length;
|
|
if (selectedRun === "latest" && hiddenCopies > 0) {
|
|
return {
|
|
label: `${scopedArtifacts.length.toLocaleString()} latest`,
|
|
title: `${hiddenCopies.toLocaleString()} older ${hiddenCopies === 1 ? "copy is" : "copies are"} hidden. Choose All runs to view them.`,
|
|
};
|
|
}
|
|
if (String(selectedRun).startsWith("run:")) {
|
|
return {
|
|
label: `${scopedArtifacts.length.toLocaleString()} in run`,
|
|
title: `${scopedArtifacts.length.toLocaleString()} artifacts in the selected workflow run.`,
|
|
};
|
|
}
|
|
return {
|
|
label: `${scopedArtifacts.length.toLocaleString()} total`,
|
|
title: `${scopedArtifacts.length.toLocaleString()} ${
|
|
artifactsTruncated ? "loaded" : "retained"
|
|
} artifacts.`,
|
|
};
|
|
}
|
|
|
|
function artifactFilterOptionHtml(kind, option, selected) {
|
|
return `
|
|
<button
|
|
class="artifact-filter-option"
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked="${selected}"
|
|
data-select-artifact-${kind}="${escapeHtml(option.value)}"
|
|
>
|
|
<span class="artifact-filter-check">${selected ? icon("check") : ""}</span>
|
|
${icon(option.icon ?? "workflow", option.className ?? "")}
|
|
<span class="artifact-filter-option-copy">${escapeHtml(option.label)}</span>
|
|
<span class="artifact-filter-option-count">${Number(option.count).toLocaleString()}</span>
|
|
</button>`;
|
|
}
|
|
|
|
function artifactFilterPickerHtml(kind, label, options, selectedValue) {
|
|
const selected = options.find((option) => option.value === selectedValue) ?? options[0];
|
|
const effectiveValue = selected.value;
|
|
const filtered =
|
|
kind === "status"
|
|
? effectiveValue !== "all"
|
|
: kind === "run"
|
|
? effectiveValue !== "latest"
|
|
: Boolean(effectiveValue);
|
|
const showSelection = kind === "run" || filtered;
|
|
return `
|
|
<div class="artifact-filter-picker">
|
|
<button
|
|
class="app-button app-button-small artifact-filter-trigger${filtered ? " is-filtered" : ""}"
|
|
type="button"
|
|
data-toggle-artifact-filter="${kind}"
|
|
aria-haspopup="menu"
|
|
aria-expanded="false"
|
|
aria-controls="artifact-${kind}-panel"
|
|
title="${escapeHtml(showSelection ? `${label}: ${selected.label}` : `Filter by ${label.toLocaleLowerCase()}`)}"
|
|
>
|
|
${icon(selected.icon ?? "workflow", selected.className ?? "")}
|
|
<span class="artifact-filter-trigger-label">${escapeHtml(showSelection ? selected.label : label)}</span>
|
|
${icon("chevron-down")}
|
|
</button>
|
|
<div id="artifact-${kind}-panel" class="artifact-filter-panel" role="menu" hidden>
|
|
<div class="artifact-filter-panel-header">
|
|
<strong>Filter by ${escapeHtml(label.toLocaleLowerCase())}</strong>
|
|
<button class="artifact-filter-close" type="button" data-close-artifact-filter aria-label="Close ${escapeHtml(label.toLocaleLowerCase())} filter">
|
|
${icon("x")}
|
|
</button>
|
|
</div>
|
|
<div class="artifact-filter-options">
|
|
${options
|
|
.map((option) =>
|
|
artifactFilterOptionHtml(kind, option, option.value === effectiveValue),
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function artifactFilterControlsHtml(artifacts) {
|
|
const statuses = artifactStatusOptions(
|
|
artifactsForRunFilter(artifacts, artifactFilters.run),
|
|
);
|
|
const runs = artifactRunOptions(
|
|
artifacts,
|
|
currentPullContext?.runsTruncated ?? false,
|
|
);
|
|
return `
|
|
<div id="artifact-filter-controls" class="artifact-filter-controls">
|
|
${artifactFilterPickerHtml("status", "Status", statuses, artifactFilters.status)}
|
|
${artifactFilterPickerHtml("run", "Run", runs, artifactFilters.run)}
|
|
</div>`;
|
|
}
|
|
|
|
function renderArtifactFilterControls() {
|
|
const controls = document.getElementById("artifact-filter-controls");
|
|
if (controls && currentPullContext) {
|
|
controls.outerHTML = artifactFilterControlsHtml(currentPullContext.artifacts);
|
|
}
|
|
}
|
|
|
|
function setArtifactFilterPanelOpen(kind, open) {
|
|
for (const candidate of ["status", "run"]) {
|
|
const panel = document.getElementById(`artifact-${candidate}-panel`);
|
|
const trigger = document.querySelector(
|
|
`[data-toggle-artifact-filter="${candidate}"]`,
|
|
);
|
|
const isOpen = candidate === kind && open;
|
|
if (panel) panel.hidden = !isOpen;
|
|
if (trigger) trigger.setAttribute("aria-expanded", String(isOpen));
|
|
trigger?.closest(".artifact-filter-picker")?.classList.toggle("is-open", isOpen);
|
|
}
|
|
}
|
|
|
|
function normalizePullListRoute(route) {
|
|
const repository = normalizeRepository(route.repository);
|
|
const state = ["open", "closed", "all"].includes(route.state) ? route.state : "open";
|
|
const filters = {
|
|
artifacts: ["with", "without"].includes(route.artifacts) ? route.artifacts : "all",
|
|
ci: ["failing", "passing", "pending", "none"].includes(route.ci) ? route.ci : "all",
|
|
};
|
|
const query =
|
|
typeof route.query === "string" && route.query.trim()
|
|
? route.query.trim()
|
|
: pullQueryForState("", state);
|
|
return {
|
|
name: "pulls",
|
|
repository,
|
|
state,
|
|
query,
|
|
...filters,
|
|
};
|
|
}
|
|
|
|
function pullListRequestPath(route) {
|
|
const parameters = new URLSearchParams({
|
|
repo: route.repository,
|
|
state: route.state,
|
|
q: route.query,
|
|
artifacts: route.artifacts,
|
|
ci: route.ci,
|
|
});
|
|
return `/api/pulls?${parameters}`;
|
|
}
|
|
|
|
function pullPayloadCacheKey(requestPath, accountId = bootstrapState?.account?.id) {
|
|
return `${accountId ?? "anonymous"}|${requestPath}`;
|
|
}
|
|
|
|
function prunePullPayloadCache() {
|
|
const now = Date.now();
|
|
for (const [key, entry] of pullPayloadCache) {
|
|
if (!entry.promise && now - entry.updatedAt > PULL_PAYLOAD_CACHE_STALE_MS) {
|
|
pullPayloadCache.delete(key);
|
|
}
|
|
}
|
|
while (pullPayloadCache.size > PULL_PAYLOAD_CACHE_LIMIT) {
|
|
const candidate = [...pullPayloadCache].find(([, entry]) => !entry.promise);
|
|
if (!candidate) return;
|
|
pullPayloadCache.delete(candidate[0]);
|
|
}
|
|
}
|
|
|
|
function cachedPullPayload(requestPath) {
|
|
const key = pullPayloadCacheKey(requestPath);
|
|
const entry = pullPayloadCache.get(key);
|
|
if (!entry?.payload) return null;
|
|
const age = Date.now() - entry.updatedAt;
|
|
if (age > PULL_PAYLOAD_CACHE_STALE_MS) {
|
|
if (!entry.promise) pullPayloadCache.delete(key);
|
|
return null;
|
|
}
|
|
pullPayloadCache.delete(key);
|
|
pullPayloadCache.set(key, entry);
|
|
return {
|
|
fresh: age <= PULL_PAYLOAD_CACHE_FRESH_MS,
|
|
payload: entry.payload,
|
|
};
|
|
}
|
|
|
|
async function requestPullPayload(requestPath, { force = false } = {}) {
|
|
const key = pullPayloadCacheKey(requestPath);
|
|
let entry = pullPayloadCache.get(key);
|
|
const age = entry?.payload ? Date.now() - entry.updatedAt : Number.POSITIVE_INFINITY;
|
|
if (!force && entry?.payload && age <= PULL_PAYLOAD_CACHE_FRESH_MS) {
|
|
return entry.payload;
|
|
}
|
|
if (entry?.promise && (!force || entry.forceRefresh)) return entry.promise;
|
|
|
|
entry ??= {
|
|
forceRefresh: false,
|
|
payload: null,
|
|
promise: null,
|
|
updatedAt: 0,
|
|
};
|
|
const separator = requestPath.includes("?") ? "&" : "?";
|
|
const target = force ? `${requestPath}${separator}refresh=1` : requestPath;
|
|
const promise = api(target).then(
|
|
(payload) => {
|
|
if (pullPayloadCache.get(key) === entry && entry.promise === promise) {
|
|
entry.forceRefresh = false;
|
|
entry.payload = payload;
|
|
entry.promise = null;
|
|
entry.updatedAt = Date.now();
|
|
const resolvedAccountId = payload?.account?.id;
|
|
const resolvedKey = resolvedAccountId
|
|
? pullPayloadCacheKey(requestPath, resolvedAccountId)
|
|
: key;
|
|
pullPayloadCache.delete(key);
|
|
pullPayloadCache.set(resolvedKey, entry);
|
|
prunePullPayloadCache();
|
|
}
|
|
return payload;
|
|
},
|
|
(error) => {
|
|
if (pullPayloadCache.get(key) === entry && entry.promise === promise) {
|
|
entry.forceRefresh = false;
|
|
entry.promise = null;
|
|
if (!entry.payload) pullPayloadCache.delete(key);
|
|
}
|
|
throw error;
|
|
},
|
|
);
|
|
entry.forceRefresh = force;
|
|
entry.promise = promise;
|
|
pullPayloadCache.set(key, entry);
|
|
prunePullPayloadCache();
|
|
return promise;
|
|
}
|
|
|
|
function clearPullPayloadCache() {
|
|
pullPayloadCache.clear();
|
|
}
|
|
|
|
function rememberPullPayload(requestPath, payload) {
|
|
const key = pullPayloadCacheKey(
|
|
requestPath,
|
|
payload?.account?.id ?? bootstrapState?.account?.id,
|
|
);
|
|
pullPayloadCache.set(key, {
|
|
forceRefresh: false,
|
|
payload,
|
|
promise: null,
|
|
updatedAt: Date.now(),
|
|
});
|
|
prunePullPayloadCache();
|
|
return payload;
|
|
}
|
|
|
|
function progressivePullRequestPath(
|
|
requestPath,
|
|
phase,
|
|
{ force = false, offset = null, sessionId = null } = {},
|
|
) {
|
|
const parameters = [
|
|
`phase=${encodeURIComponent(phase)}`,
|
|
Number.isSafeInteger(offset) ? `offset=${offset}` : null,
|
|
sessionId ? `session=${encodeURIComponent(sessionId)}` : null,
|
|
force ? "refresh=1" : null,
|
|
].filter(Boolean);
|
|
return `${requestPath}${requestPath.includes("?") ? "&" : "?"}${parameters.join("&")}`;
|
|
}
|
|
|
|
function requestInitialPullPayload(requestPath, { force = false } = {}) {
|
|
return api(
|
|
progressivePullRequestPath(requestPath, "initial", { force }),
|
|
);
|
|
}
|
|
|
|
function pullRowHtml(pull, repository) {
|
|
const authorBadge = pullAuthorBadge(pull);
|
|
const reviewStatus = pullReviewStatus(pull);
|
|
return `
|
|
<div class="Box-row pull-row">
|
|
${icon("git-pull-request", `pull-icon ${pullIconClass(pull)}`)}
|
|
<div class="Box-row-main">
|
|
<div class="pull-row-title-line">
|
|
<a class="Box-row-title" href="${pullHash(repository, pull.number)}">${titleHtml(pull.title)}</a>
|
|
${pullCheckHtml(pull)}
|
|
<span class="labels">${pull.labels.slice(0, 4).map(labelHtml).join("")}</span>
|
|
</div>
|
|
<div class="Box-row-meta pull-row-meta">
|
|
<span>${escapeHtml(pullListMeta(pull))}</span>
|
|
${authorBadge ? `<span class="author-association">${escapeHtml(authorBadge)}</span>` : ""}
|
|
${reviewStatus ? `<span class="pull-review-status">${escapeHtml(reviewStatus)}</span>` : ""}
|
|
</div>
|
|
</div>
|
|
<div class="Box-row-actions color-fg-muted">
|
|
${
|
|
pull.comments
|
|
? `<span class="pull-comments" title="${pull.comments.toLocaleString()} ${pull.comments === 1 ? "comment" : "comments"}">${icon("comment")} ${pull.comments.toLocaleString()}</span>`
|
|
: ""
|
|
}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function pullListPresentation(payload, route) {
|
|
const pulls = payload.pulls ?? [];
|
|
const totalCount = Number(payload.totalCount) || 0;
|
|
const evaluatedCount = Number(payload.evaluatedCount) || 0;
|
|
const filtered = payload.filtered === true;
|
|
const appliedFilters = payload.filters ?? {
|
|
artifacts: route.artifacts,
|
|
ci: route.ci,
|
|
};
|
|
const toolbarCount = filtered ? pulls.length : totalCount;
|
|
const heading = `${toolbarCount.toLocaleString()} ${filtered ? "matching " : ""}pull request${toolbarCount === 1 ? "" : "s"}`;
|
|
const rows = pulls.length
|
|
? pulls.map((pull) => pullRowHtml(pull, route.repository)).join("")
|
|
: `<div class="blankslate">
|
|
${icon("git-pull-request")}
|
|
<h2>No pull requests match</h2>
|
|
<p>Adjust the query or choose different filters.</p>
|
|
</div>`;
|
|
const footer = filtered
|
|
? `<div class="Box-footer">
|
|
<span>${pulls.length.toLocaleString()} match${pulls.length === 1 ? "" : "es"} from ${evaluatedCount.toLocaleString()} evaluated</span>
|
|
<span class="Box-footer-count">${totalCount.toLocaleString()} search result${totalCount === 1 ? "" : "s"}</span>
|
|
</div>`
|
|
: totalCount > pulls.length
|
|
? `<div class="Box-footer">
|
|
<span>Showing the first ${pulls.length.toLocaleString()} pull requests</span>
|
|
<span class="Box-footer-count">${totalCount.toLocaleString()} total</span>
|
|
</div>`
|
|
: "";
|
|
return { appliedFilters, footer, heading, pulls, rows };
|
|
}
|
|
|
|
function pullListBoxHtml(payload, route) {
|
|
const presentation = pullListPresentation(payload, route);
|
|
return `
|
|
<section class="Box pull-list-box" aria-label="Pull requests">
|
|
<div class="Box-header toolbar pull-list-toolbar">
|
|
<h2 class="Box-title">${icon("git-pull-request", "pull-list-heading-icon")}<span id="pull-list-heading-label">${presentation.heading}</span></h2>
|
|
<span id="pull-list-partial-label" class="Label" ${payload.incomplete ? "" : "hidden"}>Partial results</span>
|
|
<span class="toolbar-spacer"></span>
|
|
${pullAuthorPanelHtml(payload.query ?? route.query)}
|
|
${pullSignalPanelHtml("artifacts", presentation.appliedFilters.artifacts)}
|
|
${pullSignalPanelHtml("ci", presentation.appliedFilters.ci)}
|
|
${pullStatePanelHtml(route.state)}
|
|
</div>
|
|
<div id="pull-list-rows" class="pull-list-rows">${presentation.rows}</div>
|
|
<div id="pull-list-footer">${presentation.footer}</div>
|
|
</section>`;
|
|
}
|
|
|
|
function updatePullListRows(payload, route) {
|
|
const presentation = pullListPresentation(payload, route);
|
|
const heading = document.getElementById("pull-list-heading-label");
|
|
const partial = document.getElementById("pull-list-partial-label");
|
|
const rows = document.getElementById("pull-list-rows");
|
|
const footer = document.getElementById("pull-list-footer");
|
|
if (!heading || !partial || !rows || !footer) return false;
|
|
heading.textContent = presentation.heading;
|
|
partial.hidden = payload.incomplete !== true;
|
|
rows.innerHTML = presentation.rows;
|
|
footer.innerHTML = presentation.footer;
|
|
return true;
|
|
}
|
|
|
|
function pullPageHtml(payload, route) {
|
|
const query = payload.query ?? route.query;
|
|
return `
|
|
<div class="page-header">
|
|
<div>
|
|
<h1 class="page-title repo-heading pulls-repo-heading">
|
|
${icon("repo")}
|
|
<strong>${escapeHtml(route.repository)}</strong>
|
|
</h1>
|
|
<p class="page-subtitle">Search and filter pull requests with GitHub qualifiers.</p>
|
|
</div>
|
|
<div class="page-actions">
|
|
<a class="app-button" href="https://github.com/${escapeHtml(route.repository)}/pulls" target="_blank" rel="noreferrer">
|
|
${icon("link-external")} View on GitHub
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<form class="pull-query-form" data-pull-query-form>
|
|
<label class="sr-only" for="pull-query">Filter pull requests</label>
|
|
<div class="pull-query-input-wrap">
|
|
${icon("search")}
|
|
<div class="pull-query-highlight" aria-hidden="true"><span id="pull-query-highlight-content">${pullQueryHighlightHtml(query)}</span></div>
|
|
<input
|
|
id="pull-query"
|
|
class="form-control pull-query-input"
|
|
type="text"
|
|
role="combobox"
|
|
value="${escapeHtml(query)}"
|
|
placeholder="is:pr is:open author:octocat"
|
|
autocomplete="off"
|
|
autocapitalize="none"
|
|
spellcheck="false"
|
|
enterkeyhint="search"
|
|
aria-autocomplete="list"
|
|
aria-controls="pull-query-suggestions"
|
|
aria-expanded="false"
|
|
aria-haspopup="listbox"
|
|
/>
|
|
<div
|
|
id="pull-query-suggestions"
|
|
class="pull-query-suggestions"
|
|
role="listbox"
|
|
aria-label="Query suggestions"
|
|
hidden
|
|
></div>
|
|
</div>
|
|
</form>
|
|
${pullListBoxHtml(payload, route)}`;
|
|
}
|
|
|
|
function pullFilterPreferenceForRoute(route, payload) {
|
|
return {
|
|
state: route.state,
|
|
author: authorFromPullQuery(payload.query ?? route.query),
|
|
artifacts: payload.filters?.artifacts ?? route.artifacts,
|
|
ci: payload.filters?.ci ?? route.ci,
|
|
};
|
|
}
|
|
|
|
async function persistPullListPreferences(route, payload) {
|
|
const preference = persistPullFilterPreferenceClient(
|
|
route.repository,
|
|
pullFilterPreferenceForRoute(route, payload),
|
|
);
|
|
try {
|
|
bootstrapState.prefs = await api("/api/preferences", {
|
|
method: "POST",
|
|
body: {
|
|
repository: route.repository,
|
|
pullState: route.state,
|
|
pullFilter: preference,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
showToast(`Pull filters could not be saved across app restarts. ${detail}`, "error");
|
|
}
|
|
}
|
|
|
|
function updatePullFilterControls(route) {
|
|
const toolbar = document.querySelector(".pull-list-toolbar");
|
|
if (!toolbar) return;
|
|
|
|
const authorPicker = toolbar.querySelector(".pull-author-picker");
|
|
if (authorPicker) authorPicker.outerHTML = pullAuthorPanelHtml(route.query);
|
|
|
|
for (const kind of Object.keys(PULL_SIGNAL_FILTERS)) {
|
|
const picker = toolbar
|
|
.querySelector(`[data-toggle-pull-signal="${kind}"]`)
|
|
?.closest(".pull-signal-picker");
|
|
if (picker) picker.outerHTML = pullSignalPanelHtml(kind, route[kind]);
|
|
}
|
|
|
|
const statePicker = toolbar.querySelector(".pull-state-picker");
|
|
if (statePicker) statePicker.outerHTML = pullStatePanelHtml(route.state);
|
|
|
|
const input = document.getElementById("pull-query");
|
|
if (input) {
|
|
input.value = route.query;
|
|
syncPullQueryEditor(input);
|
|
setPullQuerySuggestionsOpen(input, false);
|
|
}
|
|
}
|
|
|
|
function setPullListLoading(loading) {
|
|
const box = document.querySelector(".pull-list-box");
|
|
if (!box) return;
|
|
box.classList.toggle("is-loading", loading);
|
|
if (loading) {
|
|
box.setAttribute("aria-busy", "true");
|
|
for (const button of box.querySelectorAll("button")) button.disabled = true;
|
|
if (!box.querySelector(".pull-list-progress")) {
|
|
box.querySelector(".pull-list-toolbar")?.insertAdjacentHTML(
|
|
"afterend",
|
|
`<div class="pull-list-progress" role="status" aria-live="polite">
|
|
${icon("sync", "spin")}
|
|
<span>Updating pull requests...</span>
|
|
</div>`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
box.classList.remove("is-loading");
|
|
box.removeAttribute("aria-busy");
|
|
box.querySelector(".pull-list-progress")?.remove();
|
|
for (const button of box.querySelectorAll("button")) button.disabled = false;
|
|
}
|
|
|
|
function setPullListRefreshing(refreshing, message = "Refreshing") {
|
|
const box = document.querySelector(".pull-list-box");
|
|
if (!box) return;
|
|
box.classList.toggle("is-refreshing", refreshing);
|
|
const existing = box.querySelector(".pull-list-refresh-status");
|
|
if (!refreshing) {
|
|
existing?.remove();
|
|
return;
|
|
}
|
|
if (!existing) {
|
|
box.querySelector(".pull-list-toolbar .Box-title")?.insertAdjacentHTML(
|
|
"afterend",
|
|
`<span class="pull-list-refresh-status" role="status" aria-live="polite">
|
|
${icon("sync", "spin")}
|
|
<span class="pull-list-refresh-copy">${escapeHtml(message)}</span>
|
|
</span>`,
|
|
);
|
|
return;
|
|
}
|
|
const copy = existing.querySelector(".pull-list-refresh-copy");
|
|
if (copy) copy.textContent = message;
|
|
}
|
|
|
|
function replacePullTable(payload, route, { updateHistory = true } = {}) {
|
|
updateAccountFromPayload(payload);
|
|
seedPullAuthors(route.repository, payload.pulls ?? []);
|
|
const box = document.querySelector(".pull-list-box");
|
|
if (!box) return false;
|
|
box.outerHTML = pullListBoxHtml(payload, route);
|
|
const input = document.getElementById("pull-query");
|
|
if (input) input.value = payload.query ?? route.query;
|
|
const hash = pullsHash(
|
|
route.repository,
|
|
route.state,
|
|
payload.query ?? route.query,
|
|
payload.filters ?? route,
|
|
);
|
|
if (updateHistory) history.pushState(null, "", hash);
|
|
setBreadcrumbs([
|
|
{ label: route.repository, href: hash },
|
|
{ label: "Pull requests" },
|
|
]);
|
|
return true;
|
|
}
|
|
|
|
async function continueProgressivePullList(
|
|
initialPayload,
|
|
route,
|
|
sequence,
|
|
requestPath,
|
|
{ refreshSequence = null } = {},
|
|
) {
|
|
const isActive = () =>
|
|
sequence === renderSequence &&
|
|
(refreshSequence === null ||
|
|
refreshSequence === pullTableRefreshSequence);
|
|
const batchSize =
|
|
Number(initialPayload.progressive?.batchSize) || 25;
|
|
const sourceCount =
|
|
Number(initialPayload.progressive?.sourceCount) ||
|
|
initialPayload.pulls?.length ||
|
|
0;
|
|
const sessionId = initialPayload.progressive?.sessionId;
|
|
const sourceOrder = (initialPayload.pulls ?? []).map((pull) =>
|
|
Number(pull.number));
|
|
const displayedPulls = new Map(
|
|
(initialPayload.pulls ?? []).map((pull) => [Number(pull.number), pull]),
|
|
);
|
|
let workingPayload = initialPayload;
|
|
let finalPayload = null;
|
|
|
|
try {
|
|
for (let offset = batchSize; offset < sourceCount; offset += batchSize) {
|
|
if (!isActive()) return;
|
|
setPullListRefreshing(
|
|
true,
|
|
`Loading more pull requests (${Math.min(offset, sourceCount)} of ${sourceCount})`,
|
|
);
|
|
const batch = await api(
|
|
progressivePullRequestPath(requestPath, "batch", {
|
|
offset,
|
|
sessionId,
|
|
}),
|
|
);
|
|
if (!isActive()) return;
|
|
updateAccountFromPayload(batch);
|
|
for (const pull of batch.pulls ?? []) {
|
|
const number = Number(pull.number);
|
|
if (!displayedPulls.has(number)) sourceOrder.push(number);
|
|
displayedPulls.set(number, pull);
|
|
}
|
|
workingPayload = {
|
|
...workingPayload,
|
|
pulls: sourceOrder.map((number) => displayedPulls.get(number)).filter(Boolean),
|
|
progressive: batch.progressive,
|
|
};
|
|
seedPullAuthors(route.repository, workingPayload.pulls);
|
|
updatePullListRows(workingPayload, route);
|
|
}
|
|
|
|
const filterActive =
|
|
route.artifacts !== "all" || route.ci !== "all";
|
|
setPullListRefreshing(
|
|
true,
|
|
filterActive
|
|
? "Checking CI and artifact filters"
|
|
: "Fetching checks, reviews, and comments",
|
|
);
|
|
const offsets = [];
|
|
for (let offset = 0; offset < sourceCount; offset += batchSize) {
|
|
offsets.push(offset);
|
|
}
|
|
let cursor = 0;
|
|
let workerError = null;
|
|
const worker = async () => {
|
|
try {
|
|
while (cursor < offsets.length && !finalPayload && !workerError) {
|
|
const offset = offsets[cursor++];
|
|
const details = await api(
|
|
progressivePullRequestPath(requestPath, "details", {
|
|
offset,
|
|
sessionId,
|
|
}),
|
|
);
|
|
if (workerError || !isActive()) return;
|
|
updateAccountFromPayload(details);
|
|
if (finalPayload && !details.payload) continue;
|
|
if (details.payload) {
|
|
finalPayload = {
|
|
...details.payload,
|
|
account: details.account,
|
|
repository: details.repository,
|
|
};
|
|
workingPayload = finalPayload;
|
|
} else {
|
|
for (const number of details.sourceNumbers ?? []) {
|
|
displayedPulls.delete(Number(number));
|
|
}
|
|
for (const pull of details.pulls ?? []) {
|
|
displayedPulls.set(Number(pull.number), pull);
|
|
}
|
|
workingPayload = {
|
|
...workingPayload,
|
|
pulls: sourceOrder
|
|
.map((number) => displayedPulls.get(number))
|
|
.filter(Boolean),
|
|
progressive: {
|
|
...workingPayload.progressive,
|
|
detailedCount: Number(details.completedCount) || 0,
|
|
},
|
|
};
|
|
}
|
|
if (workerError || !isActive()) return;
|
|
seedPullAuthors(route.repository, workingPayload.pulls);
|
|
updatePullListRows(workingPayload, route);
|
|
if (!finalPayload) {
|
|
setPullListRefreshing(
|
|
true,
|
|
`Fetching additional details (${Math.min(Number(details.completedCount) || 0, sourceCount)} of ${sourceCount})`,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
workerError ??= error;
|
|
}
|
|
};
|
|
await Promise.all(
|
|
Array.from(
|
|
{ length: Math.min(2, offsets.length) },
|
|
() => worker(),
|
|
),
|
|
);
|
|
if (workerError) throw workerError;
|
|
if (!isActive()) return;
|
|
if (finalPayload) {
|
|
rememberPullPayload(requestPath, finalPayload);
|
|
updateAccountFromPayload(finalPayload);
|
|
updatePullListRows(finalPayload, route);
|
|
void persistPullListPreferences(route, finalPayload);
|
|
}
|
|
setPullListRefreshing(false);
|
|
} catch (error) {
|
|
if (isActive()) {
|
|
setPullListRefreshing(false);
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshPullTable(route, { force = false } = {}) {
|
|
const nextRoute = normalizePullListRoute(route);
|
|
const currentRoute = parseRoute();
|
|
const box = document.querySelector(".pull-list-box");
|
|
if (
|
|
!box ||
|
|
currentRoute.name !== "pulls" ||
|
|
normalizeRepository(currentRoute.repository) !== nextRoute.repository
|
|
) {
|
|
location.hash = pullsHash(
|
|
nextRoute.repository,
|
|
nextRoute.state,
|
|
nextRoute.query,
|
|
nextRoute,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const previousRoute = normalizePullListRoute({
|
|
...currentRoute,
|
|
query: document.getElementById("pull-query")?.value ?? currentRoute.query,
|
|
});
|
|
const refreshSequence = ++pullTableRefreshSequence;
|
|
const activeRenderSequence = renderSequence;
|
|
const requestPath = pullListRequestPath(nextRoute);
|
|
const cached = force ? null : cachedPullPayload(requestPath);
|
|
updatePullFilterControls(nextRoute);
|
|
if (cached) {
|
|
replacePullTable(cached.payload, nextRoute);
|
|
void persistPullListPreferences(nextRoute, cached.payload);
|
|
if (cached.fresh) return;
|
|
setPullListRefreshing(true);
|
|
} else if (force) {
|
|
setPullListRefreshing(true);
|
|
} else {
|
|
setPullListLoading(true);
|
|
}
|
|
try {
|
|
const payload = cached
|
|
? await requestPullPayload(requestPath, { force })
|
|
: await requestInitialPullPayload(requestPath, { force });
|
|
if (
|
|
refreshSequence !== pullTableRefreshSequence ||
|
|
activeRenderSequence !== renderSequence
|
|
) {
|
|
return;
|
|
}
|
|
replacePullTable(payload, nextRoute, {
|
|
updateHistory: !cached && !force,
|
|
});
|
|
void persistPullListPreferences(nextRoute, payload);
|
|
if (payload.progressive?.complete === false) {
|
|
void continueProgressivePullList(
|
|
payload,
|
|
nextRoute,
|
|
activeRenderSequence,
|
|
requestPath,
|
|
{ refreshSequence },
|
|
);
|
|
} else if (!cached) {
|
|
rememberPullPayload(requestPath, payload);
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
refreshSequence === pullTableRefreshSequence &&
|
|
activeRenderSequence === renderSequence
|
|
) {
|
|
if (!cached) updatePullFilterControls(previousRoute);
|
|
setPullListLoading(false);
|
|
setPullListRefreshing(false);
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function revalidateRenderedPullList(
|
|
route,
|
|
sequence,
|
|
refreshSequence,
|
|
requestPath,
|
|
) {
|
|
setPullListRefreshing(true);
|
|
try {
|
|
const payload = await requestPullPayload(requestPath);
|
|
if (
|
|
sequence !== renderSequence ||
|
|
refreshSequence !== pullTableRefreshSequence
|
|
) {
|
|
return;
|
|
}
|
|
replacePullTable(payload, route, { updateHistory: false });
|
|
void persistPullListPreferences(route, payload);
|
|
} catch (error) {
|
|
if (
|
|
sequence === renderSequence &&
|
|
refreshSequence === pullTableRefreshSequence
|
|
) {
|
|
setPullListRefreshing(false);
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function renderPulls(route, sequence, { force = false } = {}) {
|
|
const normalizedRoute = normalizePullListRoute(route);
|
|
const pullFilters = {
|
|
artifacts: normalizedRoute.artifacts,
|
|
ci: normalizedRoute.ci,
|
|
};
|
|
const refreshSequence = ++pullTableRefreshSequence;
|
|
repositoryInput.value = rememberRepositoryClient(normalizedRoute.repository);
|
|
setBreadcrumbs([
|
|
{
|
|
label: normalizedRoute.repository,
|
|
href: pullsHash(
|
|
normalizedRoute.repository,
|
|
normalizedRoute.state,
|
|
normalizedRoute.query,
|
|
pullFilters,
|
|
),
|
|
},
|
|
{ label: "Pull requests" },
|
|
]);
|
|
const requestPath = pullListRequestPath(normalizedRoute);
|
|
const cached = force ? null : cachedPullPayload(requestPath);
|
|
if (cached) {
|
|
updateAccountFromPayload(cached.payload);
|
|
seedPullAuthors(normalizedRoute.repository, cached.payload.pulls ?? []);
|
|
view.innerHTML = pullPageHtml(cached.payload, normalizedRoute);
|
|
void persistPullListPreferences(normalizedRoute, cached.payload);
|
|
if (!cached.fresh) {
|
|
void revalidateRenderedPullList(
|
|
normalizedRoute,
|
|
sequence,
|
|
refreshSequence,
|
|
requestPath,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
loadingView();
|
|
const payload = await requestInitialPullPayload(requestPath, { force });
|
|
if (sequence !== renderSequence) return;
|
|
updateAccountFromPayload(payload);
|
|
seedPullAuthors(normalizedRoute.repository, payload.pulls ?? []);
|
|
view.innerHTML = pullPageHtml(payload, normalizedRoute);
|
|
void persistPullListPreferences(normalizedRoute, payload);
|
|
if (payload.progressive?.complete === false) {
|
|
void continueProgressivePullList(
|
|
payload,
|
|
normalizedRoute,
|
|
sequence,
|
|
requestPath,
|
|
{ refreshSequence },
|
|
);
|
|
} else {
|
|
rememberPullPayload(requestPath, payload);
|
|
}
|
|
}
|
|
|
|
function artifactRowHtml(artifact, repository, pullNumber, cachedById) {
|
|
const cached = cachedById.get(String(artifact.id));
|
|
const progress = cached ? null : artifactDownloadProgress.get(String(artifact.id));
|
|
const downloading = Boolean(progress && progress.stage !== "error");
|
|
const showTransferRow =
|
|
downloading &&
|
|
isLargeArtifactProgress(progress, artifact.sizeInBytes);
|
|
const primary = artifactPrimaryLabel(cached);
|
|
const href = cached
|
|
? artifactHash(repository, pullNumber, artifact.id)
|
|
: null;
|
|
const clickable = !artifact.expired;
|
|
return `
|
|
<div
|
|
class="Box-row artifact-row${clickable ? " is-clickable" : " is-expired"}${downloading ? " is-downloading" : ""}${showTransferRow ? " has-transfer-row" : ""}"
|
|
data-artifact-id="${artifact.id}"
|
|
data-artifact-size="${Number(artifact.sizeInBytes) || 0}"
|
|
${downloading ? 'aria-busy="true"' : ""}
|
|
${showTransferRow ? `aria-describedby="artifact-transfer-${artifact.id}"` : ""}
|
|
>
|
|
${
|
|
clickable && !downloading
|
|
? href
|
|
? `<a class="artifact-row-overlay" href="${escapeHtml(href)}" aria-label="Open ${escapeHtml(artifact.name)}"></a>`
|
|
: `<button class="artifact-row-overlay" type="button" data-inspect-artifact="${artifact.id}" data-repository="${escapeHtml(repository)}" data-pull-number="${pullNumber}" aria-label="Inspect ${escapeHtml(artifact.name)}"></button>`
|
|
: ""
|
|
}
|
|
${icon("package", "artifact-icon")}
|
|
<div class="Box-row-main">
|
|
<span class="Box-row-title">${escapeHtml(artifact.name)}</span>
|
|
${primary ? `<span class="Label ml-2">${escapeHtml(primary)}</span>` : ""}
|
|
<div class="artifact-summary mt-1">
|
|
<span class="artifact-summary-item">${runStatusIcon(artifact.run)} ${escapeHtml(artifact.run.name)}</span>
|
|
<span class="artifact-summary-item">${icon("workflow")} Run ${artifact.run.number}</span>
|
|
<span class="artifact-summary-item">${icon("archive")} ${formatBytes(artifact.sizeInBytes)}</span>
|
|
<span class="artifact-summary-item">${icon("clock")} Expires ${escapeHtml(formatDate(artifact.expiresAt))}</span>
|
|
</div>
|
|
</div>
|
|
<div class="Box-row-actions">
|
|
${
|
|
downloading
|
|
? `<button class="app-button app-button-small" type="button" disabled aria-live="polite">${icon("sync", "spin artifact-download-spinner")}<span class="artifact-download-button-label">${progress.stage === "indexing" ? "Indexing..." : "Downloading..."}</span></button>`
|
|
: artifact.expired
|
|
? '<span class="Label">Expired</span>'
|
|
: cached
|
|
? `<a class="app-button app-button-small" href="${href}">${icon("eye")} Open</a>`
|
|
: `<button class="app-button app-button-small" type="button" data-inspect-artifact="${artifact.id}" data-repository="${escapeHtml(repository)}" data-pull-number="${pullNumber}">${icon("download")} Inspect</button>`
|
|
}
|
|
</div>
|
|
</div>
|
|
${showTransferRow ? artifactTransferRowHtml(progress, artifact.id) : ""}`;
|
|
}
|
|
|
|
function artifactDownloadPhrase(progress) {
|
|
if (progress.stage === "preparing") return "Opening the artifact vault...";
|
|
if (progress.stage === "indexing") return "Download complete. Mapping the archive...";
|
|
if (progress.stage === "ready") return "Artifact ready. Opening the explorer...";
|
|
if (progress.stage === "error") return progress.message ?? "The download did not finish.";
|
|
const percent = Number(progress.percent);
|
|
if (!Number.isFinite(percent) || percent < 5) return "Warming up the byte conveyor...";
|
|
if (percent < 25) return "The bits are officially in motion.";
|
|
if (percent < 50) return "Cruising through the first half.";
|
|
if (percent < 75) return "More behind us than ahead.";
|
|
if (percent < 92) return "The finish line is in sight.";
|
|
return "Almost there - indexing is next.";
|
|
}
|
|
|
|
function artifactDownloadProgressHtml(progress) {
|
|
const receivedBytes = Math.max(0, Number(progress.receivedBytes) || 0);
|
|
const totalBytes = Number(progress.totalBytes);
|
|
const percent = Number(progress.percent);
|
|
const determinate = Number.isFinite(totalBytes) && totalBytes > 0;
|
|
const normalizedPercent = determinate && Number.isFinite(percent)
|
|
? Math.max(0, Math.min(100, percent))
|
|
: progress.stage === "indexing" || progress.stage === "ready"
|
|
? 100
|
|
: 0;
|
|
const details = [
|
|
determinate
|
|
? `${formatBytes(receivedBytes)} of ${formatBytes(totalBytes)}`
|
|
: receivedBytes
|
|
? `${formatBytes(receivedBytes)} downloaded`
|
|
: null,
|
|
Number(progress.bytesPerSecond) > 0
|
|
? `${formatBytes(progress.bytesPerSecond)}/s`
|
|
: null,
|
|
progress.stage === "downloading"
|
|
? formatRemainingTime(progress.etaSeconds)
|
|
: null,
|
|
].filter(Boolean);
|
|
const valueAttributes = determinate
|
|
? `aria-valuenow="${Math.round(normalizedPercent)}" aria-valuemin="0" aria-valuemax="100"`
|
|
: "";
|
|
return `
|
|
<div
|
|
class="artifact-download-progress${determinate ? "" : " is-indeterminate"}${progress.stage === "error" ? " is-error" : ""}"
|
|
role="progressbar"
|
|
aria-label="Downloading ${escapeHtml(progress.artifactName ?? "artifact")}"
|
|
aria-valuetext="${escapeHtml(details.join(", ") || artifactDownloadPhrase(progress))}"
|
|
${valueAttributes}
|
|
style="--artifact-download-percent:${normalizedPercent}%"
|
|
>
|
|
<div class="artifact-download-copy">
|
|
<span class="artifact-download-phrase">${escapeHtml(artifactDownloadPhrase(progress))}</span>
|
|
<span class="artifact-download-stats">${escapeHtml(details.join(" · "))}</span>
|
|
</div>
|
|
<div class="artifact-download-track" aria-hidden="true">
|
|
<span class="artifact-download-bar"></span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function isLargeArtifactProgress(progress, fallbackBytes = 0) {
|
|
const totalBytes = Number(progress?.totalBytes);
|
|
const artifactBytes =
|
|
Number.isFinite(totalBytes) && totalBytes > 0
|
|
? totalBytes
|
|
: Number(fallbackBytes);
|
|
return Number.isFinite(artifactBytes) &&
|
|
artifactBytes > LARGE_ARTIFACT_PROGRESS_BYTES;
|
|
}
|
|
|
|
function artifactTransferRowHtml(progress, artifactId) {
|
|
return `
|
|
<div
|
|
id="artifact-transfer-${artifactId}"
|
|
class="artifact-transfer-row"
|
|
data-artifact-transfer-id="${artifactId}"
|
|
>
|
|
${icon("download", "artifact-transfer-icon")}
|
|
${artifactDownloadProgressHtml(progress)}
|
|
</div>`;
|
|
}
|
|
|
|
function updateArtifactDownloadButton(button, progress) {
|
|
const label =
|
|
progress.stage === "indexing"
|
|
? "Indexing..."
|
|
: progress.stage === "ready"
|
|
? "Opening..."
|
|
: progress.stage === "error"
|
|
? "Failed"
|
|
: "Downloading...";
|
|
let spinner = button.querySelector(".artifact-download-spinner");
|
|
let copy = button.querySelector(".artifact-download-button-label");
|
|
if (!spinner || !copy) {
|
|
button.innerHTML =
|
|
`${icon("sync", "spin artifact-download-spinner")}` +
|
|
'<span class="artifact-download-button-label"></span>';
|
|
spinner = button.querySelector(".artifact-download-spinner");
|
|
copy = button.querySelector(".artifact-download-button-label");
|
|
}
|
|
spinner?.classList.toggle("spin", progress.stage !== "error");
|
|
if (copy) copy.textContent = label;
|
|
}
|
|
|
|
function updateArtifactDownloadProgress(progress) {
|
|
const artifactId = Number(progress?.artifactId);
|
|
if (!Number.isSafeInteger(artifactId) || artifactId <= 0) return;
|
|
const key = String(artifactId);
|
|
artifactDownloadProgress.set(key, progress);
|
|
const row = document.querySelector(`.artifact-row[data-artifact-id="${key}"]`);
|
|
if (!row) return;
|
|
|
|
row.classList.add("is-downloading");
|
|
row.setAttribute("aria-busy", "true");
|
|
row.querySelector(".artifact-download-progress")?.remove();
|
|
const transferSelector = `.artifact-transfer-row[data-artifact-transfer-id="${key}"]`;
|
|
const currentTransfer = document.querySelector(transferSelector);
|
|
const showTransferRow = isLargeArtifactProgress(
|
|
progress,
|
|
row.dataset.artifactSize,
|
|
);
|
|
row.classList.toggle("has-transfer-row", showTransferRow);
|
|
if (showTransferRow) {
|
|
row.setAttribute("aria-describedby", `artifact-transfer-${artifactId}`);
|
|
} else {
|
|
row.removeAttribute("aria-describedby");
|
|
}
|
|
if (showTransferRow) {
|
|
const html = artifactTransferRowHtml(progress, artifactId);
|
|
if (currentTransfer) currentTransfer.outerHTML = html;
|
|
else row.insertAdjacentHTML("afterend", html);
|
|
} else {
|
|
currentTransfer?.remove();
|
|
}
|
|
for (const control of row.querySelectorAll("button[data-inspect-artifact]")) {
|
|
control.disabled = true;
|
|
}
|
|
const statusButton = row.querySelector(".Box-row-actions button");
|
|
if (statusButton) {
|
|
statusButton.disabled = true;
|
|
statusButton.setAttribute("aria-live", "polite");
|
|
updateArtifactDownloadButton(statusButton, progress);
|
|
}
|
|
}
|
|
|
|
function renderPullArtifactRows(filter = artifactFilters.query) {
|
|
if (!currentPullContext) return;
|
|
artifactFilters.query = String(filter ?? "");
|
|
const normalizedFilter = artifactFilters.query.trim().toLocaleLowerCase();
|
|
const allArtifacts = currentPullContext.artifacts;
|
|
const scopedArtifacts = artifactsForRunFilter(allArtifacts, artifactFilters.run);
|
|
const hasContentFilters =
|
|
Boolean(normalizedFilter) || artifactFilters.status !== "all";
|
|
const matches = scopedArtifacts.filter((artifact) => {
|
|
const textMatches =
|
|
!normalizedFilter ||
|
|
artifact.name.toLocaleLowerCase().includes(normalizedFilter) ||
|
|
String(artifact.run.name ?? "Unknown workflow")
|
|
.toLocaleLowerCase()
|
|
.includes(normalizedFilter) ||
|
|
String(artifact.run.number).includes(normalizedFilter) ||
|
|
String(artifact.run.event ?? "").toLocaleLowerCase().includes(normalizedFilter) ||
|
|
String(artifact.run.conclusion ?? "").toLocaleLowerCase().includes(normalizedFilter);
|
|
const statusMatches =
|
|
artifactFilters.status === "all" ||
|
|
artifactStatus(artifact) === artifactFilters.status;
|
|
return textMatches && statusMatches;
|
|
});
|
|
const visible = matches.slice(0, 250);
|
|
const list = document.getElementById("artifact-list");
|
|
if (!list) return;
|
|
list.innerHTML = visible.length
|
|
? visible.map((artifact) => artifactRowHtml(
|
|
artifact,
|
|
currentPullContext.repository,
|
|
currentPullContext.pullNumber,
|
|
currentPullContext.cachedById,
|
|
)).join("")
|
|
: `<div class="blankslate">
|
|
${icon("search")}
|
|
<h2>No matching artifacts</h2>
|
|
<p>Try a run name, recording, report, or artifact filename.</p>
|
|
</div>`;
|
|
const summary = document.getElementById("artifact-list-summary");
|
|
if (summary) {
|
|
const total = scopedArtifacts.length;
|
|
const artifactLabel = total === 1 ? "artifact" : "artifacts";
|
|
const hiddenCopies = allArtifacts.length - scopedArtifacts.length;
|
|
const shownLabel = hasContentFilters
|
|
? !matches.length
|
|
? "No matching artifacts"
|
|
: matches.length > visible.length
|
|
? `Showing the first ${visible.length.toLocaleString()} matching artifacts`
|
|
: "All matching artifacts shown"
|
|
: artifactFilters.run === "latest" && hiddenCopies > 0
|
|
? "Latest copy of each artifact shown"
|
|
: artifactFilters.run === "all"
|
|
? currentPullContext.runsTruncated
|
|
? "All loaded runs shown"
|
|
: "All retained runs shown"
|
|
: "Selected run shown";
|
|
const totalLabel = hasContentFilters
|
|
? `${matches.length.toLocaleString()} of ${total.toLocaleString()} ${artifactLabel}`
|
|
: artifactFilters.run === "latest" && hiddenCopies > 0
|
|
? `${total.toLocaleString()} ${artifactLabel} · ${hiddenCopies.toLocaleString()} older ${hiddenCopies === 1 ? "copy" : "copies"} hidden`
|
|
: `${total.toLocaleString()} ${artifactLabel}`;
|
|
summary.innerHTML = `
|
|
<span>${escapeHtml(shownLabel)}</span>
|
|
<span class="Box-footer-count">${escapeHtml(totalLabel)}</span>`;
|
|
}
|
|
const matchedCount = document.getElementById("artifact-match-count");
|
|
if (matchedCount) {
|
|
matchedCount.hidden = !hasContentFilters;
|
|
matchedCount.textContent = `${matches.length.toLocaleString()} matched`;
|
|
}
|
|
const totalCount = document.getElementById("artifact-total-count");
|
|
if (totalCount) {
|
|
const scope = artifactScopeCountLabel(
|
|
allArtifacts,
|
|
scopedArtifacts,
|
|
artifactFilters.run,
|
|
currentPullContext.artifactsTruncated,
|
|
);
|
|
totalCount.textContent = scope.label;
|
|
totalCount.title = scope.title;
|
|
}
|
|
}
|
|
|
|
function artifactResultLimitMessage(payload) {
|
|
const artifacts = payload.artifacts ?? [];
|
|
const runs = payload.runs ?? [];
|
|
const artifactCount = Math.max(
|
|
artifacts.length,
|
|
Number(payload.artifactCount) || 0,
|
|
);
|
|
const runCount = Math.max(runs.length, Number(payload.runCount) || 0);
|
|
const messages = [];
|
|
if (payload.runsTruncated) {
|
|
messages.push(
|
|
`Showing artifacts from the newest ${runs.length.toLocaleString()} of ${runCount.toLocaleString()} workflow runs.`,
|
|
);
|
|
}
|
|
if (artifactCount > artifacts.length) {
|
|
messages.push(
|
|
`Showing the newest ${artifacts.length.toLocaleString()} of ${artifactCount.toLocaleString()} artifacts from those runs.`,
|
|
);
|
|
}
|
|
return messages.join(" ");
|
|
}
|
|
|
|
function renderPullPayload(repository, pullNumber, payload, cache) {
|
|
updateAccountFromPayload(payload);
|
|
updateCacheHeader(cache);
|
|
const pull = payload.pullRequest;
|
|
const state = pullState(pull);
|
|
const cachedById = new Map(cache.artifacts.map((item) => [String(item.id), item]));
|
|
const artifacts = payload.artifacts ?? [];
|
|
const filterContext = `${repository}#${pullNumber}`;
|
|
if (artifactFilters.context !== filterContext) {
|
|
artifactFilters = {
|
|
context: filterContext,
|
|
query: "",
|
|
status: "all",
|
|
run: "latest",
|
|
};
|
|
}
|
|
bootstrapState.prefs.repository = repository;
|
|
bootstrapState.prefs.pullNumber = pullNumber;
|
|
currentPullContext = {
|
|
artifactCount: Number(payload.artifactCount) || artifacts.length,
|
|
artifacts,
|
|
artifactsTruncated: Boolean(payload.artifactsTruncated),
|
|
cachedById,
|
|
pullNumber,
|
|
repository,
|
|
runCount: Number(payload.runCount) || (payload.runs?.length ?? 0),
|
|
runsTruncated: Boolean(payload.runsTruncated),
|
|
};
|
|
const resultLimitMessage = artifactResultLimitMessage(payload);
|
|
const initialArtifacts = artifactsForRunFilter(artifacts, artifactFilters.run);
|
|
const initialScope = artifactScopeCountLabel(
|
|
artifacts,
|
|
initialArtifacts,
|
|
artifactFilters.run,
|
|
Boolean(payload.artifactsTruncated),
|
|
);
|
|
|
|
view.innerHTML = `
|
|
<div class="page-header">
|
|
<div>
|
|
<h1 class="page-title">
|
|
<strong>${titleHtml(pull.title)}</strong>
|
|
<span class="page-title-number">#${pull.number}</span>
|
|
</h1>
|
|
</div>
|
|
<div class="page-actions">
|
|
<a class="app-button" href="${escapeHtml(pull.url)}" target="_blank" rel="noreferrer">
|
|
${icon("link-external")} View on GitHub
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div class="pr-summary">
|
|
<span class="state-badge ${state.className}">${icon("git-pull-request")} ${escapeHtml(state.label)}</span>
|
|
${pull.authorAvatarUrl ? `<img class="avatar" src="${escapeHtml(pull.authorAvatarUrl)}" alt="" />` : ""}
|
|
<span><strong>${escapeHtml(pull.author)}</strong> wants to merge into <span class="branch-name">${escapeHtml(pull.baseRef)}</span></span>
|
|
<span>Updated ${escapeHtml(relativeTime(pull.updatedAt))}</span>
|
|
</div>
|
|
<section class="Box artifact-list-box" aria-label="Workflow artifacts">
|
|
<div class="Box-header toolbar artifact-toolbar">
|
|
<h2 class="Box-title">${icon("package")} Artifacts</h2>
|
|
<span id="artifact-total-count" class="Counter ml-2" title="${escapeHtml(initialScope.title)}">${escapeHtml(initialScope.label)}</span>
|
|
<span id="artifact-match-count" class="Counter Counter--secondary ml-1" hidden></span>
|
|
<span class="toolbar-spacer"></span>
|
|
<label class="sr-only" for="artifact-filter">Filter workflow artifacts</label>
|
|
<input id="artifact-filter" class="form-control input-sm artifact-filter" type="search" placeholder="Filter artifacts" autocomplete="off" value="${escapeHtml(artifactFilters.query)}" />
|
|
${artifactFilterControlsHtml(artifacts)}
|
|
<button class="app-button app-button-small artifact-refresh" type="button" data-refresh-view>${icon("sync")} Refresh</button>
|
|
</div>
|
|
${
|
|
resultLimitMessage
|
|
? `<div class="flash flash-warn">${icon("alert")} ${escapeHtml(resultLimitMessage)}</div>`
|
|
: ""
|
|
}
|
|
${
|
|
artifacts.length
|
|
? `<div id="artifact-list"></div>
|
|
<div id="artifact-list-summary" class="Box-footer" aria-live="polite"></div>`
|
|
: `<div class="blankslate">
|
|
${icon("package")}
|
|
<h2>No workflow artifacts found</h2>
|
|
<p>${
|
|
payload.runsTruncated
|
|
? "No artifacts were found in the loaded workflow runs. Older runs were not queried."
|
|
: "No retained GitHub Actions artifacts are associated with this pull request head commit."
|
|
}</p>
|
|
</div>`
|
|
}
|
|
</section>`;
|
|
if (artifacts.length) renderPullArtifactRows();
|
|
}
|
|
|
|
async function revalidateRenderedPull(
|
|
repository,
|
|
pullNumber,
|
|
sequence,
|
|
requestPath,
|
|
) {
|
|
try {
|
|
const payload = await requestPullPayload(requestPath);
|
|
if (sequence !== renderSequence) return;
|
|
renderPullPayload(
|
|
repository,
|
|
pullNumber,
|
|
payload,
|
|
bootstrapState.cache,
|
|
);
|
|
} catch (error) {
|
|
if (sequence === renderSequence) {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function renderPull(route, sequence, { force = false } = {}) {
|
|
const repository = normalizeRepository(route.repository);
|
|
const pullNumber = Number(route.pullNumber);
|
|
const requestPath =
|
|
`/api/pulls/${pullNumber}?repo=${encodeURIComponent(repository)}`;
|
|
repositoryInput.value = rememberRepositoryClient(repository);
|
|
setBreadcrumbs([
|
|
{ label: repository, href: pullsHashForRepository(repository) },
|
|
{ label: "Pull requests", href: pullsHashForRepository(repository) },
|
|
{ label: `#${pullNumber}` },
|
|
]);
|
|
|
|
const cached = force ? null : cachedPullPayload(requestPath);
|
|
if (cached && bootstrapState.cache) {
|
|
renderPullPayload(
|
|
repository,
|
|
pullNumber,
|
|
cached.payload,
|
|
bootstrapState.cache,
|
|
);
|
|
if (!cached.fresh) {
|
|
void revalidateRenderedPull(
|
|
repository,
|
|
pullNumber,
|
|
sequence,
|
|
requestPath,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
loadingView();
|
|
const [payload, cache] = await Promise.all([
|
|
requestPullPayload(requestPath, { force }),
|
|
api("/api/cache"),
|
|
]);
|
|
if (sequence !== renderSequence) return;
|
|
renderPullPayload(repository, pullNumber, payload, cache);
|
|
}
|
|
|
|
function fileIconName(kind) {
|
|
if (kind === "asciinema") return "terminal";
|
|
if (kind === "html") return "browser";
|
|
if (kind === "image") return "file-media";
|
|
if (kind === "markdown") return "markdown";
|
|
if (kind === "trx") return "check-circle";
|
|
if (kind === "json" || kind === "text" || kind === "xml") return "file-code";
|
|
if (kind === "archive") return "file-zip";
|
|
if (kind === "pdf") return "file";
|
|
return "file-binary";
|
|
}
|
|
|
|
function artifactEntryKind(entry) {
|
|
if (/\.trx$/i.test(String(entry.path ?? entry.name ?? ""))) {
|
|
return "trx";
|
|
}
|
|
if (
|
|
entry.kind === "text" &&
|
|
String(entry.mime ?? "").toLocaleLowerCase().includes("xml")
|
|
) {
|
|
return "xml";
|
|
}
|
|
return entry.kind;
|
|
}
|
|
|
|
function rootIndexHtmlEntry(entries) {
|
|
return entries.find((entry) => {
|
|
const path = String(entry.path ?? "")
|
|
.replaceAll("\\", "/")
|
|
.replace(/^\.\/+/, "");
|
|
return (
|
|
entry.supported &&
|
|
!path.includes("/") &&
|
|
path.toLocaleLowerCase() === "index.html" &&
|
|
artifactEntryKind(entry) === "html"
|
|
);
|
|
});
|
|
}
|
|
|
|
function shouldUseStaticSitePreview(entry, entries) {
|
|
return rootIndexHtmlEntry(entries) === entry;
|
|
}
|
|
|
|
function directoryPathsForFile(path) {
|
|
const parts = String(path ?? "").split("/").filter(Boolean);
|
|
const directories = [];
|
|
for (let index = 1; index < parts.length; index++) {
|
|
directories.push(parts.slice(0, index).join("/"));
|
|
}
|
|
return directories;
|
|
}
|
|
|
|
function createArtifactFileTreeState(entries, selectedPath) {
|
|
const expandedDirectories = new Set();
|
|
if (entries.length <= EXPAND_ALL_FILE_TREE_LIMIT) {
|
|
for (const entry of entries) {
|
|
for (const path of directoryPathsForFile(entry.path)) {
|
|
expandedDirectories.add(path);
|
|
}
|
|
}
|
|
}
|
|
for (const path of directoryPathsForFile(selectedPath)) {
|
|
expandedDirectories.add(path);
|
|
}
|
|
return {
|
|
expandedDirectories,
|
|
excludedExtensions: new Set(),
|
|
filterMenuOpen: false,
|
|
includeVendoredFiles: true,
|
|
includeViewedFiles: true,
|
|
onlyManifestFiles: false,
|
|
viewedPaths: new Set(selectedPath ? [selectedPath] : []),
|
|
};
|
|
}
|
|
|
|
function artifactFileExtension(entry) {
|
|
const name = String(entry.name ?? entry.path?.split("/").at(-1) ?? "");
|
|
const separator = name.lastIndexOf(".");
|
|
return separator > 0 && separator < name.length - 1
|
|
? name.slice(separator).toLocaleLowerCase()
|
|
: "";
|
|
}
|
|
|
|
function isManifestArtifactEntry(entry) {
|
|
const name = String(entry.name ?? entry.path?.split("/").at(-1) ?? "")
|
|
.toLocaleLowerCase();
|
|
return (
|
|
MANIFEST_FILE_NAMES.has(name) ||
|
|
/^requirements(?:[._-].*)?\.txt$/.test(name) ||
|
|
/\.(?:csproj|fsproj|nuspec|proj|props|sln|slnx|targets|vbproj)$/.test(name)
|
|
);
|
|
}
|
|
|
|
function isVendoredArtifactEntry(entry) {
|
|
return String(entry.path ?? "")
|
|
.replaceAll("\\", "/")
|
|
.toLocaleLowerCase()
|
|
.split("/")
|
|
.some((segment) => VENDORED_PATH_SEGMENTS.has(segment));
|
|
}
|
|
|
|
function fileTreeFiltersActive(state = currentArtifact?.fileTree) {
|
|
return Boolean(
|
|
state &&
|
|
(
|
|
state.excludedExtensions.size ||
|
|
state.onlyManifestFiles ||
|
|
!state.includeVendoredFiles ||
|
|
!state.includeViewedFiles
|
|
)
|
|
);
|
|
}
|
|
|
|
function fileTreeExtensionOptions(entries) {
|
|
const counts = new Map();
|
|
for (const entry of entries) {
|
|
const extension = artifactFileExtension(entry);
|
|
counts.set(extension, (counts.get(extension) ?? 0) + 1);
|
|
}
|
|
return [...counts.entries()]
|
|
.map(([extension, count]) => ({
|
|
count,
|
|
extension,
|
|
label: extension || "No extension",
|
|
}))
|
|
.sort((left, right) => {
|
|
if (!left.extension) return 1;
|
|
if (!right.extension) return -1;
|
|
return FILE_PATH_COLLATOR.compare(left.extension, right.extension);
|
|
});
|
|
}
|
|
|
|
function fileTreeFilterCheckHtml(checked) {
|
|
return checked ? icon("check") : "";
|
|
}
|
|
|
|
function fileTreeFilterControlHtml(entries) {
|
|
const state = currentArtifact.fileTree;
|
|
const active = fileTreeFiltersActive(state);
|
|
return `
|
|
<div
|
|
id="file-tree-filter-root"
|
|
class="file-tree-filter-root${state.filterMenuOpen ? " is-open" : ""}"
|
|
data-file-tree-filter-root
|
|
>
|
|
<button
|
|
class="app-button app-button-icon-only file-tree-filter-trigger${active ? " is-filtered" : ""}"
|
|
type="button"
|
|
data-toggle-file-tree-filter
|
|
aria-label="Filter artifact files"
|
|
aria-haspopup="menu"
|
|
aria-expanded="${state.filterMenuOpen}"
|
|
aria-controls="file-tree-filter-menu"
|
|
title="Filter files"
|
|
>${icon("filter")}</button>
|
|
<div
|
|
id="file-tree-filter-menu"
|
|
class="file-tree-filter-menu"
|
|
role="menu"
|
|
${state.filterMenuOpen ? "" : "hidden"}
|
|
>
|
|
<div class="file-tree-filter-heading">File extensions</div>
|
|
<div class="file-tree-filter-options" role="group" aria-label="File extensions">
|
|
${fileTreeExtensionOptions(entries).map((option) => {
|
|
const checked = !state.excludedExtensions.has(option.extension);
|
|
return `
|
|
<button
|
|
class="file-tree-filter-option"
|
|
type="button"
|
|
role="menuitemcheckbox"
|
|
aria-checked="${checked}"
|
|
data-file-tree-extension="${escapeHtml(option.extension)}"
|
|
title="${escapeHtml(option.label)}"
|
|
>
|
|
<span class="file-tree-filter-check">${fileTreeFilterCheckHtml(checked)}</span>
|
|
<span class="file-tree-filter-copy">${escapeHtml(option.label)}</span>
|
|
<span class="Counter Counter--secondary">${option.count.toLocaleString()}</span>
|
|
</button>`;
|
|
}).join("")}
|
|
</div>
|
|
<div class="file-tree-filter-categories" role="group" aria-label="File visibility">
|
|
<button
|
|
class="file-tree-filter-option"
|
|
type="button"
|
|
role="menuitemcheckbox"
|
|
aria-checked="${state.onlyManifestFiles}"
|
|
data-file-tree-category="manifest"
|
|
>
|
|
<span class="file-tree-filter-check">${fileTreeFilterCheckHtml(state.onlyManifestFiles)}</span>
|
|
<span class="file-tree-filter-copy">Only manifest files</span>
|
|
</button>
|
|
<button
|
|
class="file-tree-filter-option"
|
|
type="button"
|
|
role="menuitemcheckbox"
|
|
aria-checked="${state.includeVendoredFiles}"
|
|
data-file-tree-category="vendored"
|
|
>
|
|
<span class="file-tree-filter-check">${fileTreeFilterCheckHtml(state.includeVendoredFiles)}</span>
|
|
<span class="file-tree-filter-copy">Vendored files</span>
|
|
</button>
|
|
<button
|
|
class="file-tree-filter-option"
|
|
type="button"
|
|
role="menuitemcheckbox"
|
|
aria-checked="${state.includeViewedFiles}"
|
|
data-file-tree-category="viewed"
|
|
>
|
|
<span class="file-tree-filter-check">${fileTreeFilterCheckHtml(state.includeViewedFiles)}</span>
|
|
<span class="file-tree-filter-copy">Viewed files</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function syncFileTreeFilterUi() {
|
|
const state = currentArtifact?.fileTree;
|
|
if (!state) return;
|
|
const root = document.getElementById("file-tree-filter-root");
|
|
const trigger = root?.querySelector("[data-toggle-file-tree-filter]");
|
|
const menu = document.getElementById("file-tree-filter-menu");
|
|
root?.classList.toggle("is-open", state.filterMenuOpen);
|
|
trigger?.classList.toggle("is-filtered", fileTreeFiltersActive(state));
|
|
trigger?.setAttribute("aria-expanded", String(state.filterMenuOpen));
|
|
if (menu) menu.hidden = !state.filterMenuOpen;
|
|
for (const option of root?.querySelectorAll("[data-file-tree-extension]") ?? []) {
|
|
const checked = !state.excludedExtensions.has(option.dataset.fileTreeExtension);
|
|
option.setAttribute("aria-checked", String(checked));
|
|
option.querySelector(".file-tree-filter-check").innerHTML =
|
|
fileTreeFilterCheckHtml(checked);
|
|
}
|
|
for (const option of root?.querySelectorAll("[data-file-tree-category]") ?? []) {
|
|
const checked =
|
|
option.dataset.fileTreeCategory === "manifest"
|
|
? state.onlyManifestFiles
|
|
: option.dataset.fileTreeCategory === "vendored"
|
|
? state.includeVendoredFiles
|
|
: state.includeViewedFiles;
|
|
option.setAttribute("aria-checked", String(checked));
|
|
option.querySelector(".file-tree-filter-check").innerHTML =
|
|
fileTreeFilterCheckHtml(checked);
|
|
}
|
|
}
|
|
|
|
function entryMatchesFileTreeFilters(entry, filter) {
|
|
const state = currentArtifact.fileTree;
|
|
if (filter && !entry.path.toLocaleLowerCase().includes(filter)) return false;
|
|
if (state.excludedExtensions.has(artifactFileExtension(entry))) return false;
|
|
if (state.onlyManifestFiles && !isManifestArtifactEntry(entry)) return false;
|
|
if (!state.includeVendoredFiles && isVendoredArtifactEntry(entry)) return false;
|
|
if (!state.includeViewedFiles && state.viewedPaths.has(entry.path)) return false;
|
|
return true;
|
|
}
|
|
|
|
function buildArtifactFileTree(entries) {
|
|
const root = { directories: new Map(), files: [], name: "", path: "" };
|
|
for (const entry of entries) {
|
|
const parts = String(entry.path ?? "").split("/").filter(Boolean);
|
|
if (!parts.length) continue;
|
|
let directory = root;
|
|
for (let index = 0; index < parts.length - 1; index++) {
|
|
const name = parts[index];
|
|
const path = parts.slice(0, index + 1).join("/");
|
|
if (!directory.directories.has(name)) {
|
|
directory.directories.set(name, {
|
|
directories: new Map(),
|
|
files: [],
|
|
name,
|
|
path,
|
|
});
|
|
}
|
|
directory = directory.directories.get(name);
|
|
}
|
|
directory.files.push(entry);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
function compactArtifactFileTreeDirectory(directory) {
|
|
let current = directory;
|
|
let name = directory.name;
|
|
while (current.files.length === 0 && current.directories.size === 1) {
|
|
const child = current.directories.values().next().value;
|
|
name = `${name}/${child.name}`;
|
|
current = child;
|
|
}
|
|
return {
|
|
directory: current,
|
|
name,
|
|
path: current.path,
|
|
};
|
|
}
|
|
|
|
function renderArtifactFileTree(directory, selectedPath, state, expandMatches, depth = 0) {
|
|
const directories = [...directory.directories.values()].sort((left, right) =>
|
|
FILE_PATH_COLLATOR.compare(left.name, right.name));
|
|
const files = [...directory.files].sort((left, right) =>
|
|
FILE_PATH_COLLATOR.compare(left.name, right.name) ||
|
|
FILE_PATH_COLLATOR.compare(left.path, right.path));
|
|
return [
|
|
...directories.map((child) => {
|
|
const compacted = compactArtifactFileTreeDirectory(child);
|
|
const expanded =
|
|
expandMatches || state.expandedDirectories.has(compacted.path);
|
|
return `
|
|
<li class="file-tree-item">
|
|
<button
|
|
class="file-tree-row file-tree-directory"
|
|
type="button"
|
|
data-file-directory="${escapeHtml(compacted.path)}"
|
|
aria-expanded="${expanded}"
|
|
title="${escapeHtml(compacted.path)}"
|
|
style="--file-tree-depth:${depth}"
|
|
>
|
|
${icon(expanded ? "chevron-down" : "chevron-right", "file-tree-chevron")}
|
|
${icon("file-directory", "file-tree-folder")}
|
|
<span class="file-tree-name">${escapeHtml(compacted.name)}</span>
|
|
</button>
|
|
${expanded
|
|
? `<ul class="file-tree-group" aria-label="${escapeHtml(compacted.name)}">${renderArtifactFileTree(compacted.directory, selectedPath, state, expandMatches, depth + 1)}</ul>`
|
|
: ""}
|
|
</li>`;
|
|
}),
|
|
...files.map((entry) => `
|
|
<li class="file-tree-item">
|
|
<button
|
|
type="button"
|
|
class="file-tree-row file-tree-file"
|
|
data-artifact-file="${escapeHtml(entry.path)}"
|
|
aria-current="${entry.path === selectedPath ? "true" : "false"}"
|
|
title="${escapeHtml(entry.path)}"
|
|
style="--file-tree-depth:${depth}"
|
|
>
|
|
<span class="file-tree-spacer" aria-hidden="true"></span>
|
|
${icon(fileIconName(artifactEntryKind(entry)), "file-tree-file-icon")}
|
|
<span class="file-tree-name">${escapeHtml(entry.name)}</span>
|
|
<span class="file-tree-size">${formatBytes(entry.size)}</span>
|
|
</button>
|
|
</li>`),
|
|
].join("");
|
|
}
|
|
|
|
function renderFileRows(entries, selectedPath, filter = "") {
|
|
const normalizedFilter = filter.trim().toLocaleLowerCase();
|
|
const matches = entries
|
|
.filter((entry) => entryMatchesFileTreeFilters(entry, normalizedFilter))
|
|
.sort((left, right) => FILE_PATH_COLLATOR.compare(left.path, right.path));
|
|
let visible = matches.slice(0, MAX_VISIBLE_ARTIFACT_FILES);
|
|
const selectedIndex = matches.findIndex((entry) => entry.path === selectedPath);
|
|
if (selectedIndex >= MAX_VISIBLE_ARTIFACT_FILES) {
|
|
visible = [
|
|
...visible.slice(0, MAX_VISIBLE_ARTIFACT_FILES - 1),
|
|
matches[selectedIndex],
|
|
].sort((left, right) => FILE_PATH_COLLATOR.compare(left.path, right.path));
|
|
}
|
|
const list = document.getElementById("file-list");
|
|
if (!list) return;
|
|
const state = currentArtifact.fileTree;
|
|
const expandMatches = Boolean(normalizedFilter) || fileTreeFiltersActive(state);
|
|
list.innerHTML = visible.length
|
|
? renderArtifactFileTree(
|
|
buildArtifactFileTree(visible),
|
|
selectedPath,
|
|
state,
|
|
expandMatches,
|
|
)
|
|
: `<li class="p-3 color-fg-muted">No files match these filters.</li>`;
|
|
const summary = document.getElementById("file-list-summary");
|
|
if (summary) {
|
|
summary.textContent =
|
|
matches.length > visible.length
|
|
? `Showing ${visible.length.toLocaleString()} of ${matches.length.toLocaleString()} files`
|
|
: `${matches.length.toLocaleString()} file${matches.length === 1 ? "" : "s"}`;
|
|
}
|
|
}
|
|
|
|
function encodedEntryUrl(artifactId, entryPath, download = false) {
|
|
const encodedPath = String(entryPath)
|
|
.split("/")
|
|
.map((part) => encodeURIComponent(part))
|
|
.join("/");
|
|
const search = new URLSearchParams({ token: capabilityToken });
|
|
if (download) search.set("download", "1");
|
|
return `/content/${artifactId}/${encodedPath}?${search}`;
|
|
}
|
|
|
|
function disposePlayer() {
|
|
if (currentPlayer?.dispose) {
|
|
try {
|
|
currentPlayer.dispose();
|
|
} catch {
|
|
// The player may already have disposed itself during navigation.
|
|
}
|
|
}
|
|
currentPlayer = null;
|
|
currentPlayerIsPlaying = false;
|
|
}
|
|
|
|
const PLAYER_FONT_FAMILIES = {
|
|
system: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
|
|
cascadia: '"Cascadia Mono", "Cascadia Code", Consolas, monospace',
|
|
consolas: 'Consolas, "Liberation Mono", monospace',
|
|
};
|
|
|
|
const PLAYER_SETTING_OPTIONS = {
|
|
fontSize: ["12px", "14px", "16px", "18px", "20px", "22px"],
|
|
fontFamily: [
|
|
{ value: "system", label: "System" },
|
|
{ value: "cascadia", label: "Cascadia" },
|
|
{ value: "consolas", label: "Consolas" },
|
|
],
|
|
lineHeight: [
|
|
{ value: 1, label: "Tight" },
|
|
{ value: 1.2, label: "Normal" },
|
|
{ value: 1.4, label: "Relaxed" },
|
|
{ value: 1.6, label: "Spacious" },
|
|
],
|
|
speed: [
|
|
{ value: 0.5, label: "0.5x" },
|
|
{ value: 1, label: "1x" },
|
|
{ value: 1.5, label: "1.5x" },
|
|
{ value: 2, label: "2x" },
|
|
],
|
|
};
|
|
|
|
function playerPreferences() {
|
|
bootstrapState.prefs.player ??= {
|
|
fontSize: "16px",
|
|
fontFamily: "system",
|
|
lineHeight: 1.2,
|
|
speed: 1,
|
|
};
|
|
return bootstrapState.prefs.player;
|
|
}
|
|
|
|
function playerSegmentedControl(name, label, values, current) {
|
|
return `
|
|
<div class="player-control-group" role="group" aria-label="${escapeHtml(label)}">
|
|
<span class="player-control-label">${escapeHtml(label)}</span>
|
|
<span class="player-segmented">
|
|
${values
|
|
.map(
|
|
({ value, label: optionLabel }) => `
|
|
<button
|
|
class="player-segment"
|
|
type="button"
|
|
data-player-setting="${escapeHtml(name)}"
|
|
data-player-value="${escapeHtml(value)}"
|
|
aria-pressed="${String(current) === String(value)}"
|
|
>${escapeHtml(optionLabel)}</button>`,
|
|
)
|
|
.join("")}
|
|
</span>
|
|
</div>`;
|
|
}
|
|
|
|
function playerTextSizeControl(current) {
|
|
const values = PLAYER_SETTING_OPTIONS.fontSize;
|
|
const index = Math.max(0, values.indexOf(current));
|
|
return `
|
|
<div class="player-control-group player-text-size" role="group" aria-label="Text size">
|
|
<span class="player-control-label">Text size</span>
|
|
<span class="player-stepper">
|
|
<button class="player-step" type="button" data-player-step="-1" aria-label="Decrease text size"${index === 0 ? " disabled" : ""}>A-</button>
|
|
<output id="player-font-size-value" class="player-step-value">${escapeHtml(current.replace("px", " px"))}</output>
|
|
<button class="player-step" type="button" data-player-step="1" aria-label="Increase text size"${index === values.length - 1 ? " disabled" : ""}>A+</button>
|
|
</span>
|
|
</div>`;
|
|
}
|
|
|
|
function syncPlayerPreferenceControls() {
|
|
const preferences = playerPreferences();
|
|
for (const control of document.querySelectorAll("[data-player-setting][data-player-value]")) {
|
|
control.setAttribute(
|
|
"aria-pressed",
|
|
String(String(preferences[control.dataset.playerSetting]) === control.dataset.playerValue),
|
|
);
|
|
}
|
|
const fontSizeValue = document.getElementById("player-font-size-value");
|
|
if (fontSizeValue) {
|
|
fontSizeValue.textContent = preferences.fontSize.replace("px", " px");
|
|
}
|
|
const fontSizeIndex = PLAYER_SETTING_OPTIONS.fontSize.indexOf(preferences.fontSize);
|
|
for (const step of document.querySelectorAll("[data-player-step]")) {
|
|
const direction = Number(step.dataset.playerStep);
|
|
step.disabled =
|
|
(direction < 0 && fontSizeIndex <= 0) ||
|
|
(direction > 0 && fontSizeIndex >= PLAYER_SETTING_OPTIONS.fontSize.length - 1);
|
|
}
|
|
}
|
|
|
|
function mountAsciinemaPlayer(entry, preservePlayback = false) {
|
|
const artifact = currentArtifact;
|
|
const host = document.getElementById("asciinema-host");
|
|
if (!artifact || !host || !window.AsciinemaPlayer?.create) {
|
|
throw new Error("The asciinema player did not load.");
|
|
}
|
|
const playbackTime =
|
|
preservePlayback && currentPlayer?.getCurrentTime
|
|
? Number(currentPlayer.getCurrentTime()) || 0
|
|
: 0;
|
|
const resumePlayback = preservePlayback && currentPlayerIsPlaying;
|
|
disposePlayer();
|
|
host.replaceChildren();
|
|
const preferences = playerPreferences();
|
|
const player = window.AsciinemaPlayer.create(
|
|
encodedEntryUrl(artifact.id, entry.path),
|
|
host,
|
|
{
|
|
autoplay: false,
|
|
controls: true,
|
|
fit: false,
|
|
idleTimeLimit: 2,
|
|
preload: true,
|
|
speed: Number(preferences.speed),
|
|
terminalFontFamily:
|
|
PLAYER_FONT_FAMILIES[preferences.fontFamily] ?? PLAYER_FONT_FAMILIES.system,
|
|
terminalFontSize: preferences.fontSize,
|
|
terminalLineHeight: Number(preferences.lineHeight),
|
|
},
|
|
);
|
|
currentPlayer = player;
|
|
player.addEventListener?.("play", () => {
|
|
if (currentPlayer === player) currentPlayerIsPlaying = true;
|
|
});
|
|
player.addEventListener?.("playing", () => {
|
|
if (currentPlayer === player) currentPlayerIsPlaying = true;
|
|
});
|
|
player.addEventListener?.("pause", () => {
|
|
if (currentPlayer === player) currentPlayerIsPlaying = false;
|
|
});
|
|
player.addEventListener?.("ended", () => {
|
|
if (currentPlayer === player) currentPlayerIsPlaying = false;
|
|
});
|
|
|
|
if (preservePlayback) {
|
|
const restorePosition =
|
|
playbackTime > 0 && player.seek ? player.seek(playbackTime) : Promise.resolve();
|
|
void Promise.resolve(restorePosition)
|
|
.then(() => {
|
|
if (resumePlayback && currentPlayer === player) return player.play();
|
|
return null;
|
|
})
|
|
.catch((error) => {
|
|
if (currentPlayer === player) {
|
|
showToast(
|
|
`Playback position could not be restored: ${error instanceof Error ? error.message : String(error)}`,
|
|
"error",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderAsciinemaPreview(entry, preview) {
|
|
const preferences = playerPreferences();
|
|
preview.innerHTML = `
|
|
<div class="asciinema-preview">
|
|
<div id="asciinema-host" class="asciinema-host"></div>
|
|
<div class="player-control-bar" aria-label="Recording playback and display settings">
|
|
${playerTextSizeControl(preferences.fontSize)}
|
|
${playerSegmentedControl(
|
|
"fontFamily",
|
|
"Font",
|
|
PLAYER_SETTING_OPTIONS.fontFamily,
|
|
preferences.fontFamily,
|
|
)}
|
|
${playerSegmentedControl(
|
|
"lineHeight",
|
|
"Line height",
|
|
PLAYER_SETTING_OPTIONS.lineHeight,
|
|
preferences.lineHeight,
|
|
)}
|
|
${playerSegmentedControl(
|
|
"speed",
|
|
"Speed",
|
|
PLAYER_SETTING_OPTIONS.speed,
|
|
preferences.speed,
|
|
)}
|
|
</div>
|
|
</div>`;
|
|
mountAsciinemaPlayer(entry);
|
|
}
|
|
|
|
async function updatePlayerPreference(key, value) {
|
|
if (!["fontSize", "fontFamily", "lineHeight", "speed"].includes(key)) return;
|
|
const numeric = key === "lineHeight" || key === "speed";
|
|
const nextValue = numeric ? Number(value) : String(value);
|
|
const allowed = PLAYER_SETTING_OPTIONS[key].some((option) =>
|
|
String(typeof option === "object" ? option.value : option) === String(nextValue));
|
|
if (!allowed) return;
|
|
const previous = { ...playerPreferences() };
|
|
bootstrapState.prefs.player = {
|
|
...previous,
|
|
[key]: nextValue,
|
|
};
|
|
syncPlayerPreferenceControls();
|
|
const entry = currentArtifact?.analysis.entries.find(
|
|
(item) => item.path === currentArtifact.selectedPath,
|
|
);
|
|
try {
|
|
if (entry?.kind === "asciinema") mountAsciinemaPlayer(entry, true);
|
|
bootstrapState.prefs = await api("/api/preferences", {
|
|
method: "POST",
|
|
body: { player: bootstrapState.prefs.player },
|
|
});
|
|
syncPlayerPreferenceControls();
|
|
} catch (error) {
|
|
bootstrapState.prefs.player = previous;
|
|
syncPlayerPreferenceControls();
|
|
let restoreError = null;
|
|
if (entry?.kind === "asciinema") {
|
|
try {
|
|
mountAsciinemaPlayer(entry, true);
|
|
} catch (playerError) {
|
|
restoreError = playerError;
|
|
}
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
showToast(
|
|
restoreError
|
|
? `${message}. Restoring the player also failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`
|
|
: message,
|
|
"error",
|
|
);
|
|
}
|
|
}
|
|
|
|
function stepPlayerTextSize(direction) {
|
|
const values = PLAYER_SETTING_OPTIONS.fontSize;
|
|
const currentIndex = values.indexOf(playerPreferences().fontSize);
|
|
const nextIndex = Math.min(
|
|
values.length - 1,
|
|
Math.max(0, currentIndex + Number(direction)),
|
|
);
|
|
if (nextIndex !== currentIndex) {
|
|
void updatePlayerPreference("fontSize", values[nextIndex]);
|
|
}
|
|
}
|
|
|
|
async function previewArtifactFile(entry, sequence) {
|
|
disposePlayer();
|
|
const artifact = currentArtifact;
|
|
if (!artifact || sequence !== renderSequence) return;
|
|
const preview = document.getElementById("preview-content");
|
|
const previewPath = document.getElementById("preview-path");
|
|
const downloadLink = document.getElementById("preview-download");
|
|
if (!preview || !previewPath || !downloadLink) return;
|
|
|
|
previewPath.textContent = entry.path;
|
|
downloadLink.href = encodedEntryUrl(artifact.id, entry.path, true);
|
|
downloadLink.innerHTML = entry.supported
|
|
? `${icon("download")} Download`
|
|
: `${icon("download")} Download artifact ZIP`;
|
|
downloadLink.title = entry.supported
|
|
? `Download ${entry.path}`
|
|
: "This ZIP entry cannot be extracted safely; download the original artifact.";
|
|
downloadLink.hidden = false;
|
|
preview.innerHTML = `
|
|
<div class="loading-list w-100">
|
|
<div class="skeleton-row"><span class="skeleton"></span><span class="skeleton"></span><span class="skeleton"></span></div>
|
|
</div>`;
|
|
|
|
try {
|
|
const entryKind = artifactEntryKind(entry);
|
|
if (
|
|
entryKind === "html" &&
|
|
shouldUseStaticSitePreview(entry, artifact.analysis.entries)
|
|
) {
|
|
const theme = resolvedCanvasTheme();
|
|
const result = await api(
|
|
`/api/artifacts/${artifact.id}/preview-url?path=${encodeURIComponent(entry.path)}&theme=${theme}`,
|
|
);
|
|
if (sequence !== renderSequence || currentArtifact?.selectedPath !== entry.path) return;
|
|
// The preview uses a dedicated loopback origin, so storage access remains isolated from the canvas.
|
|
preview.innerHTML = `
|
|
<iframe
|
|
class="preview-frame"
|
|
data-theme-aware-preview
|
|
src="${escapeHtml(result.url)}"
|
|
title="${escapeHtml(entry.path)}"
|
|
sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-downloads"
|
|
referrerpolicy="no-referrer"
|
|
></iframe>`;
|
|
const frame = preview.querySelector("iframe[data-theme-aware-preview]");
|
|
if (frame) {
|
|
frame.style.colorScheme = theme;
|
|
frame.addEventListener("load", syncPreviewTheme);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (entryKind === "asciinema") {
|
|
renderAsciinemaPreview(entry, preview);
|
|
return;
|
|
}
|
|
|
|
if (entryKind === "image") {
|
|
preview.innerHTML = `<img class="preview-image" src="${encodedEntryUrl(artifact.id, entry.path)}" alt="${escapeHtml(entry.path)}" />`;
|
|
return;
|
|
}
|
|
|
|
if (entryKind === "pdf") {
|
|
preview.innerHTML = `
|
|
<iframe
|
|
class="preview-frame"
|
|
src="${encodedEntryUrl(artifact.id, entry.path)}"
|
|
title="${escapeHtml(entry.path)}"
|
|
sandbox
|
|
referrerpolicy="no-referrer"
|
|
></iframe>`;
|
|
return;
|
|
}
|
|
|
|
if (["json", "markdown", "text", "trx", "xml"].includes(entryKind)) {
|
|
const result = await api(
|
|
`/api/artifacts/${artifact.id}/text?path=${encodeURIComponent(entry.path)}`,
|
|
);
|
|
if (sequence !== renderSequence || currentArtifact?.selectedPath !== entry.path) return;
|
|
if (entryKind === "trx") {
|
|
TrxPreview.render(result.content, preview, { highlightXml });
|
|
return;
|
|
}
|
|
let content = result.content;
|
|
if (entryKind === "json") {
|
|
try {
|
|
content = JSON.stringify(JSON.parse(content), null, 2);
|
|
} catch {
|
|
// Preserve malformed JSON as source text.
|
|
}
|
|
}
|
|
preview.innerHTML = entryKind === "xml"
|
|
? `<pre class="text-preview syntax-code syntax-xml"><code>${highlightXml(content)}</code></pre>`
|
|
: entryKind === "json"
|
|
? `<pre class="text-preview syntax-code syntax-json"><code>${highlightJson(content)}</code></pre>`
|
|
: `<pre class="text-preview">${escapeHtml(content)}</pre>`;
|
|
return;
|
|
}
|
|
|
|
preview.innerHTML = `
|
|
<div class="preview-empty">
|
|
<div class="preview-empty-inner">
|
|
${icon(fileIconName(entryKind))}
|
|
<h2 class="h4 mb-2">No inline preview</h2>
|
|
<p class="mb-3">Download this ${escapeHtml(entryKind)} file to inspect it locally.</p>
|
|
<a class="app-button" href="${encodedEntryUrl(artifact.id, entry.path, true)}">${icon("download")} Download</a>
|
|
</div>
|
|
</div>`;
|
|
} catch (error) {
|
|
preview.innerHTML = `
|
|
<div class="flash flash-error w-100">
|
|
<strong>Preview failed</strong>
|
|
<div class="mt-1">${escapeHtml(error instanceof Error ? error.message : String(error))}</div>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function artifactBreadcrumbItems(artifact, selectedPath) {
|
|
const items = [
|
|
{
|
|
label: artifact.repository,
|
|
href: pullsHashForRepository(artifact.repository),
|
|
},
|
|
{
|
|
label: "Pull requests",
|
|
href: pullsHashForRepository(artifact.repository),
|
|
},
|
|
];
|
|
if (artifact.pullNumber > 0) {
|
|
items.push({
|
|
label: `#${artifact.pullNumber}`,
|
|
href: pullHash(artifact.repository, artifact.pullNumber),
|
|
});
|
|
}
|
|
items.push({
|
|
label: artifact.metadata.artifact.name,
|
|
});
|
|
if (selectedPath) {
|
|
items.push({
|
|
label: selectedPath,
|
|
compactLabel: selectedPath.split("/").filter(Boolean).at(-1) ?? selectedPath,
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function preferRootIndexSite(analysis) {
|
|
const rootIndex = rootIndexHtmlEntry(analysis.entries);
|
|
if (!rootIndex) {
|
|
return ["html", "static-site"].includes(analysis.primary?.kind)
|
|
? { ...analysis, primary: null }
|
|
: analysis;
|
|
}
|
|
return {
|
|
...analysis,
|
|
primary: {
|
|
kind: "static-site",
|
|
path: rootIndex.path,
|
|
root: "",
|
|
label: "Static HTML site",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function selectArtifactFile(path, updateHash = true) {
|
|
if (!currentArtifact) return;
|
|
const entry = currentArtifact.analysis.entries.find((item) => item.path === path);
|
|
if (!entry) {
|
|
showToast("The selected file is no longer present.", "error");
|
|
return;
|
|
}
|
|
currentArtifact.selectedPath = path;
|
|
currentArtifact.fileTree.viewedPaths.add(path);
|
|
for (const directory of directoryPathsForFile(path)) {
|
|
currentArtifact.fileTree.expandedDirectories.add(directory);
|
|
}
|
|
renderFileRows(
|
|
currentArtifact.analysis.entries,
|
|
path,
|
|
document.getElementById("file-filter")?.value ?? "",
|
|
);
|
|
setBreadcrumbs(artifactBreadcrumbItems(currentArtifact, path));
|
|
if (updateHash) {
|
|
history.replaceState(
|
|
null,
|
|
"",
|
|
artifactHash(
|
|
currentArtifact.repository,
|
|
currentArtifact.pullNumber,
|
|
currentArtifact.id,
|
|
path,
|
|
),
|
|
);
|
|
}
|
|
await previewArtifactFile(entry, renderSequence);
|
|
}
|
|
|
|
function updateSidebarUi(collapsed) {
|
|
const explorer = document.getElementById("artifact-explorer");
|
|
const button = document.querySelector("[data-toggle-sidebar]");
|
|
if (!explorer || !button) return;
|
|
explorer.classList.toggle("is-sidebar-collapsed", collapsed);
|
|
button.innerHTML = icon(collapsed ? "sidebar-expand" : "sidebar-collapse");
|
|
button.setAttribute("aria-expanded", String(!collapsed));
|
|
button.setAttribute(
|
|
"aria-label",
|
|
collapsed ? "Show artifact file sidebar" : "Hide artifact file sidebar",
|
|
);
|
|
button.title = collapsed ? "Show file sidebar" : "Hide file sidebar";
|
|
}
|
|
|
|
async function toggleSidebar() {
|
|
bootstrapState.prefs.explorer ??= { sidebarCollapsed: false };
|
|
const previous = bootstrapState.prefs.explorer.sidebarCollapsed === true;
|
|
const collapsed = !previous;
|
|
bootstrapState.prefs.explorer.sidebarCollapsed = collapsed;
|
|
updateSidebarUi(collapsed);
|
|
try {
|
|
bootstrapState.prefs = await api("/api/preferences", {
|
|
method: "POST",
|
|
body: { explorer: { sidebarCollapsed: collapsed } },
|
|
});
|
|
} catch (error) {
|
|
bootstrapState.prefs.explorer.sidebarCollapsed = previous;
|
|
updateSidebarUi(previous);
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
}
|
|
}
|
|
|
|
async function renderArtifact(route, sequence) {
|
|
const requestedRepository = normalizeRepository(route.repository);
|
|
const metadata = await api(`/api/artifacts/${route.artifactId}`);
|
|
if (sequence !== renderSequence) return;
|
|
const repository = normalizeRepository(metadata.repository ?? requestedRepository);
|
|
repositoryInput.value = rememberRepositoryClient(repository);
|
|
const analysis = preferRootIndexSite(metadata.analysis);
|
|
const preferredPath =
|
|
route.filePath &&
|
|
analysis.entries.some((entry) => entry.path === route.filePath)
|
|
? route.filePath
|
|
: analysis.primary?.path ??
|
|
analysis.entries.find((entry) => artifactEntryKind(entry) !== "html")?.path ??
|
|
analysis.entries[0]?.path ??
|
|
null;
|
|
|
|
currentArtifact = {
|
|
id: Number(route.artifactId),
|
|
repository,
|
|
pullNumber: Number(route.pullNumber) || 0,
|
|
metadata,
|
|
analysis,
|
|
fileTree: createArtifactFileTreeState(analysis.entries, preferredPath),
|
|
selectedPath: preferredPath,
|
|
};
|
|
setBreadcrumbs(artifactBreadcrumbItems(currentArtifact, preferredPath));
|
|
const primary = analysis.primary;
|
|
|
|
view.innerHTML = `
|
|
<div class="page-header">
|
|
<div>
|
|
<h1 class="page-title repo-heading">
|
|
${icon("package")}
|
|
<strong>${escapeHtml(metadata.artifact.name)}</strong>
|
|
</h1>
|
|
<p class="page-subtitle">
|
|
${primary ? escapeHtml(primary.label) : "Artifact files"}
|
|
<span class="Label ml-2">ZIP-backed</span>
|
|
</p>
|
|
</div>
|
|
<div class="page-actions">
|
|
<button class="app-button app-button-danger" type="button" data-delete-artifact="${route.artifactId}">
|
|
${icon("trash")} Delete download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="artifact-summary mb-3">
|
|
<span class="artifact-summary-item">${icon("archive")} ${formatBytes(metadata.compressedBytes)} downloaded</span>
|
|
<span class="artifact-summary-item">${icon("file")} ${analysis.fileCount.toLocaleString()} files</span>
|
|
<span class="artifact-summary-item">${icon("package")} ${formatBytes(analysis.totalUncompressedBytes)} expanded size</span>
|
|
<span class="artifact-summary-item">${icon("clock")} Cached ${escapeHtml(relativeTime(metadata.downloadedAt))}</span>
|
|
</div>
|
|
<section id="artifact-explorer" class="explorer${bootstrapState.prefs.explorer?.sidebarCollapsed ? " is-sidebar-collapsed" : ""}" aria-label="Artifact explorer">
|
|
<aside class="file-panel">
|
|
<div class="file-panel-header">
|
|
<div class="file-filter-toolbar">
|
|
<div class="file-filter-wrap">
|
|
${icon("search", "file-filter-icon")}
|
|
<label class="sr-only" for="file-filter">Filter artifact files</label>
|
|
<input id="file-filter" class="form-control file-filter" type="search" placeholder="Filter files..." autocomplete="off" />
|
|
</div>
|
|
${fileTreeFilterControlHtml(analysis.entries)}
|
|
</div>
|
|
<div id="file-list-summary" class="color-fg-muted f6 mt-2"></div>
|
|
</div>
|
|
<ul id="file-list" class="file-list" aria-label="Artifact files"></ul>
|
|
</aside>
|
|
<div class="preview-panel">
|
|
<div class="preview-toolbar">
|
|
<button
|
|
class="app-button app-button-invisible app-button-small app-button-icon-only"
|
|
type="button"
|
|
data-toggle-sidebar
|
|
aria-label="${bootstrapState.prefs.explorer?.sidebarCollapsed ? "Show" : "Hide"} artifact file sidebar"
|
|
aria-expanded="${bootstrapState.prefs.explorer?.sidebarCollapsed ? "false" : "true"}"
|
|
title="${bootstrapState.prefs.explorer?.sidebarCollapsed ? "Show" : "Hide"} file sidebar"
|
|
>${icon(bootstrapState.prefs.explorer?.sidebarCollapsed ? "sidebar-expand" : "sidebar-collapse")}</button>
|
|
${icon("eye")}
|
|
<span id="preview-path" class="preview-path">${preferredPath ? escapeHtml(preferredPath) : "Select a file"}</span>
|
|
<a id="preview-download" class="app-button app-button-small" href="#" hidden>${icon("download")} Download</a>
|
|
</div>
|
|
<div id="preview-content" class="preview-content">
|
|
<div class="preview-empty">
|
|
<div class="preview-empty-inner">
|
|
${icon("file")}
|
|
<h2 class="h4 mb-2">Select a file</h2>
|
|
<p>Choose an artifact file to preview it.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>`;
|
|
|
|
renderFileRows(analysis.entries, preferredPath);
|
|
if (preferredPath) await selectArtifactFile(preferredPath, false);
|
|
}
|
|
|
|
async function renderCache(sequence) {
|
|
const repository = String(bootstrapState?.prefs?.repository ?? "");
|
|
const pullNumber = Number(bootstrapState?.prefs?.pullNumber);
|
|
const pullsHref = pullsHashForRepository(repository);
|
|
const hasPull = Number.isSafeInteger(pullNumber) && pullNumber > 0;
|
|
const returnHref = hasPull
|
|
? pullHash(repository, pullNumber)
|
|
: pullsHref;
|
|
const returnLabel = hasPull
|
|
? `Back to #${pullNumber}`
|
|
: "Back to pull requests";
|
|
setBreadcrumbs([
|
|
{ label: repository, href: pullsHref },
|
|
{ label: "Pull requests", href: pullsHref },
|
|
...(hasPull ? [{ label: `#${pullNumber}`, href: returnHref }] : []),
|
|
{ label: "Artifact cache" },
|
|
]);
|
|
loadingView();
|
|
const cache = await api("/api/cache");
|
|
if (sequence !== renderSequence) return;
|
|
updateCacheHeader(cache);
|
|
|
|
view.innerHTML = `
|
|
<div class="page-header">
|
|
<div>
|
|
<h1 class="page-title repo-heading">${icon("package")} <strong>Artifact cache</strong></h1>
|
|
<p class="page-subtitle">Downloaded ZIP files are stored without extraction.</p>
|
|
</div>
|
|
<div class="page-actions">
|
|
<a class="app-button" href="${escapeHtml(returnHref)}">
|
|
${icon("chevron-left")} ${escapeHtml(returnLabel)}
|
|
</a>
|
|
<button class="app-button app-button-danger" type="button" data-clear-cache${cache.count ? "" : " disabled"}>
|
|
${icon("trash")} Clear all
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<section class="Box" aria-label="Cached artifacts">
|
|
<div class="Box-header toolbar">
|
|
<h2 class="Box-title">${cache.count} cached artifact${cache.count === 1 ? "" : "s"}</h2>
|
|
<span class="toolbar-spacer"></span>
|
|
<span class="cache-stats">${formatBytes(cache.totalBytes)}</span>
|
|
</div>
|
|
${
|
|
cache.artifacts.length
|
|
? cache.artifacts.map((item) => `
|
|
<div class="Box-row cached-artifact-row is-clickable">
|
|
<a class="artifact-row-overlay" href="${artifactHash(item.repository, 0, item.id)}" aria-label="Open ${escapeHtml(item.name)}"></a>
|
|
${icon("package", "artifact-icon")}
|
|
<div class="Box-row-main">
|
|
<span class="Box-row-title">${escapeHtml(item.name)}</span>
|
|
${item.primary?.label ? `<span class="Label ml-2">${escapeHtml(item.primary.label)}</span>` : ""}
|
|
<div class="Box-row-meta">
|
|
${escapeHtml(item.repository)}. ${formatBytes(item.bytes)}. Last opened ${escapeHtml(relativeTime(item.lastAccessedAt))}.
|
|
</div>
|
|
</div>
|
|
<div class="Box-row-actions">
|
|
<button class="app-button app-button-small app-button-danger" type="button" data-delete-artifact="${item.id}">${icon("trash")} Delete</button>
|
|
</div>
|
|
</div>`).join("")
|
|
: `<div class="blankslate">
|
|
${icon("archive")}
|
|
<h2>No downloaded artifacts</h2>
|
|
<p>Inspect an artifact from a pull request to add it here.</p>
|
|
<div class="blankslate-actions">
|
|
<a class="app-button" href="${escapeHtml(returnHref)}">
|
|
${icon("chevron-left")} ${escapeHtml(returnLabel)}
|
|
</a>
|
|
</div>
|
|
</div>`
|
|
}
|
|
</section>
|
|
${
|
|
cache.errors?.length
|
|
? `<div class="flash flash-warn mt-3">${escapeHtml(cache.errors.map((item) => item.message).join(" "))}</div>`
|
|
: ""
|
|
}`;
|
|
}
|
|
|
|
function accountAccess(account) {
|
|
if (account.status === "failed") {
|
|
return { label: "Credential failed", className: "failed", icon: "x-circle" };
|
|
}
|
|
if (account.repositoryAccess === false) {
|
|
return { label: "Limited access", className: "limited", icon: "x-circle" };
|
|
}
|
|
return { label: "Repository access", className: "available", icon: "check-circle" };
|
|
}
|
|
|
|
function accountAvatarHtml(account) {
|
|
return account.avatarUrl
|
|
? `<img class="account-card-avatar" src="${escapeHtml(account.avatarUrl)}" alt="" />`
|
|
: `<span class="account-card-avatar account-card-avatar-fallback">${icon("person")}</span>`;
|
|
}
|
|
|
|
function accountCardHtml(account, activeId, repository) {
|
|
const active = account.id === activeId;
|
|
const access = accountAccess(account);
|
|
const scopes = account.scopes?.length
|
|
? account.scopes.map((scope) => `<code>${escapeHtml(scope)}</code>`).join(" ")
|
|
: '<span class="color-fg-muted">No OAuth scopes reported</span>';
|
|
const sources = (account.sources ?? [])
|
|
.map(
|
|
(source) => `
|
|
<div class="credential-source">
|
|
<span class="credential-source-icon">${icon(
|
|
source.status === "failed" ? "x-circle" : "check-circle",
|
|
)}</span>
|
|
<span class="credential-source-copy">
|
|
<span class="credential-source-name">
|
|
${escapeHtml(source.label)}
|
|
${source.chosen ? '<span class="Label">In use</span>' : ""}
|
|
</span>
|
|
<span class="credential-source-meta">
|
|
${source.repositoryAccess === false ? "No access to this repository" : "Available"}
|
|
${source.scopes?.length ? ` - ${escapeHtml(source.scopes.join(", "))}` : ""}
|
|
</span>
|
|
${source.reason ? `<span class="credential-source-error">${escapeHtml(source.reason)}</span>` : ""}
|
|
</span>
|
|
</div>`,
|
|
)
|
|
.join("");
|
|
return `
|
|
<article class="account-card${active ? " is-active" : ""}">
|
|
<button
|
|
class="account-card-main"
|
|
type="button"
|
|
data-select-account="${escapeHtml(account.id)}"
|
|
aria-pressed="${active}"
|
|
${active || account.status === "failed" ? "disabled" : ""}
|
|
>
|
|
${accountAvatarHtml(account)}
|
|
<span class="account-card-identity">
|
|
<span class="account-card-title">
|
|
<strong>${escapeHtml(account.name || account.login)}</strong>
|
|
${active ? '<span class="Label Label--success">Active</span>' : ""}
|
|
</span>
|
|
<span class="account-card-login">@${escapeHtml(account.login)}</span>
|
|
${account.company ? `<span class="account-card-company">${escapeHtml(account.company)}</span>` : ""}
|
|
</span>
|
|
<span class="account-access ${access.className}">
|
|
${icon(access.icon)}
|
|
${escapeHtml(access.label)}
|
|
</span>
|
|
</button>
|
|
<div class="account-card-footer">
|
|
<span class="account-scope-list">${scopes}</span>
|
|
${
|
|
account.profileUrl
|
|
? `<a class="app-button app-button-small" href="${escapeHtml(account.profileUrl)}" target="_blank" rel="noreferrer">${icon("link-external")} View profile</a>`
|
|
: ""
|
|
}
|
|
</div>
|
|
<details class="account-sources">
|
|
<summary>
|
|
<span>${(account.sources?.length ?? 0).toLocaleString()} credential source${account.sources?.length === 1 ? "" : "s"} for ${escapeHtml(repository)}</span>
|
|
${icon("chevron-down", "account-sources-chevron")}
|
|
</summary>
|
|
<div class="credential-source-list">${sources}</div>
|
|
</details>
|
|
</article>`;
|
|
}
|
|
|
|
async function renderAccountsPage(sequence) {
|
|
const repository = bootstrapState.prefs.repository;
|
|
setBreadcrumbs([{ label: "GitHub accounts" }]);
|
|
accountButton.classList.add("is-active");
|
|
loadingView();
|
|
const payload = await api(`/api/accounts?repo=${encodeURIComponent(repository)}`);
|
|
if (sequence !== renderSequence) return;
|
|
updateAccountFromPayload(payload);
|
|
const accounts = bootstrapState.accounts ?? [];
|
|
view.innerHTML = `
|
|
<div class="page-header">
|
|
<div>
|
|
<h1 class="page-title repo-heading">${icon("person")} <strong>GitHub accounts</strong></h1>
|
|
<p class="page-subtitle">Credentials inherited from the Copilot app and GitHub CLI.</p>
|
|
</div>
|
|
<div class="page-actions">
|
|
<button class="app-button" type="button" data-refresh-accounts>${icon("sync")} Rescan accounts</button>
|
|
</div>
|
|
</div>
|
|
<div class="flash account-repository-context">
|
|
Account access is checked against <strong>${escapeHtml(repository)}</strong>.
|
|
</div>
|
|
<section class="account-list" aria-label="Available GitHub accounts">
|
|
${
|
|
accounts.length
|
|
? accounts
|
|
.map((account) =>
|
|
accountCardHtml(account, bootstrapState.account?.id, repository),
|
|
)
|
|
.join("")
|
|
: `<div class="blankslate">
|
|
${icon("person")}
|
|
<h2>No GitHub accounts detected</h2>
|
|
<p>Sign in through the Copilot app or GitHub CLI, then rescan.</p>
|
|
</div>`
|
|
}
|
|
</section>`;
|
|
}
|
|
|
|
async function refreshAccounts(button) {
|
|
const original = button.innerHTML;
|
|
button.disabled = true;
|
|
button.innerHTML = `${icon("sync", "spin")} Rescanning...`;
|
|
try {
|
|
const payload = await api(
|
|
`/api/accounts?repo=${encodeURIComponent(bootstrapState.prefs.repository)}`,
|
|
);
|
|
clearPullPayloadCache();
|
|
updateAccountFromPayload(payload);
|
|
await renderRoute();
|
|
showToast("GitHub accounts rescanned.");
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
button.disabled = false;
|
|
button.innerHTML = original;
|
|
}
|
|
}
|
|
|
|
async function selectAccount(accountId, button) {
|
|
button.disabled = true;
|
|
try {
|
|
const payload = await api("/api/account", {
|
|
method: "POST",
|
|
body: {
|
|
id: accountId,
|
|
repository: bootstrapState.prefs.repository,
|
|
},
|
|
});
|
|
clearPullPayloadCache();
|
|
updateAccountFromPayload(payload);
|
|
await renderRoute();
|
|
showToast(`Using @${payload.account?.login ?? "selected account"}.`);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function renderRoute({ force = false } = {}) {
|
|
disposePlayer();
|
|
currentArtifact = null;
|
|
currentPullContext = null;
|
|
const sequence = ++renderSequence;
|
|
const route = parseRoute();
|
|
accountButton.classList.toggle("is-active", route.name === "accounts");
|
|
try {
|
|
if (route.name === "pulls") await renderPulls(route, sequence, { force });
|
|
else if (route.name === "pull") await renderPull(route, sequence, { force });
|
|
else if (route.name === "artifact") {
|
|
setBreadcrumbs([
|
|
{ label: route.repository, href: pullsHashForRepository(route.repository) },
|
|
{ label: "Pull requests", href: pullsHashForRepository(route.repository) },
|
|
{ label: "Artifact" },
|
|
]);
|
|
loadingView();
|
|
await renderArtifact(route, sequence);
|
|
} else if (route.name === "cache") await renderCache(sequence);
|
|
else if (route.name === "accounts") await renderAccountsPage(sequence);
|
|
} catch (error) {
|
|
if (sequence === renderSequence) renderError(error);
|
|
}
|
|
}
|
|
|
|
function repositoryTargetFromInput(value) {
|
|
const input = String(value ?? "").trim();
|
|
let match = input.match(
|
|
/^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)(?:[/?#].*)?$/i,
|
|
);
|
|
if (match) {
|
|
return {
|
|
repository: `${match[1]}/${match[2]}`,
|
|
pullNumber: Number(match[3]),
|
|
};
|
|
}
|
|
match = input.match(/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#(\d+)$/);
|
|
if (match) {
|
|
return {
|
|
repository: match[1],
|
|
pullNumber: Number(match[2]),
|
|
};
|
|
}
|
|
return { repository: normalizeRepository(input), pullNumber: null };
|
|
}
|
|
|
|
function routeFromInput(value) {
|
|
const target = repositoryTargetFromInput(value);
|
|
return target.pullNumber
|
|
? pullHash(target.repository, target.pullNumber)
|
|
: pullsHashForRepository(target.repository);
|
|
}
|
|
|
|
async function navigateToRepository(value) {
|
|
const target = repositoryTargetFromInput(value);
|
|
const searchIcon = repositoryCombobox.querySelector(".repository-search-icon");
|
|
repositoryInput.readOnly = true;
|
|
repositoryMenuButton.disabled = true;
|
|
searchIcon?.classList.replace("icon-search", "icon-sync");
|
|
searchIcon?.classList.add("spin");
|
|
repositoryInput.setAttribute("aria-busy", "true");
|
|
repositoryInput.removeAttribute("aria-invalid");
|
|
try {
|
|
const payload = await api("/api/repositories/validate", {
|
|
method: "POST",
|
|
body: { repository: target.repository },
|
|
});
|
|
const repository = rememberRepositoryClient(payload.repository.fullName);
|
|
repositoryInput.value = repository;
|
|
setRepositoryPickerOpen(false);
|
|
location.hash = target.pullNumber
|
|
? pullHash(repository, target.pullNumber)
|
|
: pullsHashForRepository(repository);
|
|
} catch (error) {
|
|
repositoryInput.setAttribute("aria-invalid", "true");
|
|
repositorySearchError = error instanceof Error ? error.message : String(error);
|
|
setRepositoryPickerOpen(true);
|
|
renderRepositoryPicker();
|
|
throw error;
|
|
} finally {
|
|
repositoryInput.readOnly = false;
|
|
repositoryMenuButton.disabled = false;
|
|
searchIcon?.classList.replace("icon-sync", "icon-search");
|
|
searchIcon?.classList.remove("spin");
|
|
repositoryInput.removeAttribute("aria-busy");
|
|
}
|
|
}
|
|
|
|
async function inspectArtifactButton(button) {
|
|
const artifactId = Number(button.dataset.inspectArtifact);
|
|
const repository = button.dataset.repository;
|
|
const pullNumber = Number(button.dataset.pullNumber);
|
|
const row = button.closest(".artifact-row");
|
|
const controls = row
|
|
? [...row.querySelectorAll("button[data-inspect-artifact]")]
|
|
: [button];
|
|
const statusButton =
|
|
row?.querySelector(".Box-row-actions button[data-inspect-artifact]") ?? button;
|
|
const original = statusButton.innerHTML;
|
|
const originalLabel = statusButton.getAttribute("aria-label");
|
|
const artifact = currentPullContext?.artifacts.find(
|
|
(item) => Number(item.id) === artifactId,
|
|
);
|
|
updateArtifactDownloadProgress({
|
|
artifactId,
|
|
artifactName: artifact?.name,
|
|
stage: "preparing",
|
|
receivedBytes: 0,
|
|
totalBytes: artifact?.sizeInBytes ?? null,
|
|
percent: 0,
|
|
bytesPerSecond: 0,
|
|
etaSeconds: null,
|
|
});
|
|
for (const control of controls) control.disabled = true;
|
|
statusButton.setAttribute("aria-label", "Downloading artifact");
|
|
try {
|
|
await api("/api/artifacts/inspect", {
|
|
method: "POST",
|
|
body: { artifactId, repository },
|
|
});
|
|
updateCacheHeader(await api("/api/cache"));
|
|
artifactDownloadProgress.delete(String(artifactId));
|
|
location.hash = artifactHash(repository, pullNumber, artifactId);
|
|
} catch (error) {
|
|
artifactDownloadProgress.delete(String(artifactId));
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
row?.classList.remove("is-downloading");
|
|
row?.classList.remove("has-transfer-row");
|
|
row?.removeAttribute("aria-busy");
|
|
row?.removeAttribute("aria-describedby");
|
|
row?.querySelector(".artifact-download-progress")?.remove();
|
|
document
|
|
.querySelector(`.artifact-transfer-row[data-artifact-transfer-id="${artifactId}"]`)
|
|
?.remove();
|
|
for (const control of controls) control.disabled = false;
|
|
statusButton.removeAttribute("aria-live");
|
|
if (originalLabel === null) statusButton.removeAttribute("aria-label");
|
|
else statusButton.setAttribute("aria-label", originalLabel);
|
|
statusButton.innerHTML = original;
|
|
}
|
|
}
|
|
|
|
async function deleteArtifact(artifactId) {
|
|
if (!window.confirm("Delete this downloaded artifact from the local cache?")) return;
|
|
try {
|
|
await api(`/api/cache/${artifactId}`, { method: "DELETE" });
|
|
updateCacheHeader(await api("/api/cache"));
|
|
if (currentArtifact?.id === Number(artifactId)) {
|
|
location.hash = "#/cache";
|
|
} else {
|
|
await renderRoute();
|
|
}
|
|
showToast("Downloaded artifact deleted.");
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
}
|
|
}
|
|
|
|
async function clearCache() {
|
|
if (!window.confirm("Delete every downloaded artifact from the local cache?")) return;
|
|
try {
|
|
await api("/api/cache", { method: "DELETE" });
|
|
updateCacheHeader(await api("/api/cache"));
|
|
await renderRoute();
|
|
showToast("Artifact cache cleared.");
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
}
|
|
}
|
|
|
|
repositoryForm.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
void navigateToRepository(repositoryInput.value).catch((error) => {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
});
|
|
});
|
|
|
|
repositoryInput.addEventListener("focus", () => {
|
|
if (repositoryPanel.hidden) setRepositoryPickerOpen(true, { resetQuery: true });
|
|
});
|
|
|
|
repositoryInput.addEventListener("input", () => {
|
|
repositoryInput.removeAttribute("aria-invalid");
|
|
scheduleRepositorySearch(repositoryInput.value);
|
|
});
|
|
|
|
repositoryInput.addEventListener("keydown", (event) => {
|
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
if (repositoryPanel.hidden) setRepositoryPickerOpen(true, { resetQuery: true });
|
|
moveRepositorySelection(event.key === "ArrowDown" ? 1 : -1);
|
|
return;
|
|
}
|
|
if (event.key === "Enter" && !repositoryPanel.hidden && repositoryActiveIndex >= 0) {
|
|
const option = repositoryResults.querySelector(
|
|
`[data-repository-option-index="${repositoryActiveIndex}"]`,
|
|
);
|
|
if (option) {
|
|
event.preventDefault();
|
|
void navigateToRepository(option.dataset.selectRepository).catch((error) => {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === "Escape" && !repositoryPanel.hidden) {
|
|
event.preventDefault();
|
|
setRepositoryPickerOpen(false);
|
|
}
|
|
});
|
|
|
|
repositoryMenuButton.addEventListener("click", () => {
|
|
const open = repositoryPanel.hidden;
|
|
setRepositoryPickerOpen(open, { resetQuery: open });
|
|
if (open) repositoryInput.focus();
|
|
});
|
|
|
|
repositoryResults.addEventListener("click", (event) => {
|
|
const pinButton = event.target.closest("[data-pin-repository]");
|
|
if (pinButton) {
|
|
event.preventDefault();
|
|
void setPinnedRepository(pinButton.dataset.pinRepository).catch((error) => {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
});
|
|
return;
|
|
}
|
|
const favoriteButton = event.target.closest("[data-favorite-repository]");
|
|
if (favoriteButton) {
|
|
event.preventDefault();
|
|
void setFavoriteRepository(
|
|
favoriteButton.dataset.favoriteRepository,
|
|
favoriteButton.dataset.favorite === "true",
|
|
).catch((error) => {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
});
|
|
return;
|
|
}
|
|
const option = event.target.closest("[data-select-repository]");
|
|
if (option) {
|
|
event.preventDefault();
|
|
void navigateToRepository(option.dataset.selectRepository).catch((error) => {
|
|
showToast(error instanceof Error ? error.message : String(error), "error");
|
|
});
|
|
}
|
|
});
|
|
|
|
repositoryResults.addEventListener("pointermove", (event) => {
|
|
const option = event.target.closest("[data-repository-option-index]");
|
|
if (!option) return;
|
|
const index = Number(option.dataset.repositoryOptionIndex);
|
|
if (index !== repositoryActiveIndex) {
|
|
repositoryActiveIndex = index;
|
|
for (const item of repositoryResults.querySelectorAll("[data-repository-option-index]")) {
|
|
const active = Number(item.dataset.repositoryOptionIndex) === repositoryActiveIndex;
|
|
item.classList.toggle("is-active", active);
|
|
item.setAttribute("aria-selected", String(active));
|
|
}
|
|
}
|
|
});
|
|
|
|
view.addEventListener(
|
|
"pointerdown",
|
|
(event) => {
|
|
if (event.target.closest("[data-pull-query-suggestion]")) {
|
|
event.preventDefault();
|
|
}
|
|
},
|
|
true,
|
|
);
|
|
|
|
view.addEventListener("pointermove", (event) => {
|
|
const option = event.target.closest("[data-pull-query-suggestion]");
|
|
if (!option) return;
|
|
const index = Number(option.dataset.pullQuerySuggestion);
|
|
if (index === pullQuerySuggestionIndex) return;
|
|
pullQuerySuggestionIndex = index;
|
|
const input = document.getElementById("pull-query");
|
|
for (const candidate of document.querySelectorAll("[data-pull-query-suggestion]")) {
|
|
const active =
|
|
Number(candidate.dataset.pullQuerySuggestion) === pullQuerySuggestionIndex;
|
|
candidate.classList.toggle("is-active", active);
|
|
candidate.setAttribute("aria-selected", String(active));
|
|
}
|
|
input?.setAttribute(
|
|
"aria-activedescendant",
|
|
`pull-query-suggestion-${pullQuerySuggestionIndex}`,
|
|
);
|
|
});
|
|
|
|
document.addEventListener("pointerdown", (event) => {
|
|
if (!repositoryCombobox.contains(event.target)) setRepositoryPickerOpen(false);
|
|
if (!event.target.closest?.(".pull-query-input-wrap")) {
|
|
setPullQuerySuggestionsOpen(document.getElementById("pull-query"), false);
|
|
}
|
|
if (!event.target.closest?.(".pull-state-picker")) setPullStatePanelOpen(false);
|
|
if (!event.target.closest?.(".pull-author-picker")) setPullAuthorPanelOpen(false);
|
|
if (!event.target.closest?.(".pull-signal-picker")) {
|
|
setPullSignalPanelOpen(null, false);
|
|
}
|
|
if (!event.target.closest?.(".breadcrumb-overflow")) {
|
|
setBreadcrumbOverflowOpen(false);
|
|
}
|
|
if (!event.target.closest?.(".artifact-filter-picker")) {
|
|
setArtifactFilterPanelOpen(null, false);
|
|
}
|
|
if (
|
|
currentArtifact?.fileTree?.filterMenuOpen &&
|
|
!event.target.closest?.("[data-file-tree-filter-root]")
|
|
) {
|
|
currentArtifact.fileTree.filterMenuOpen = false;
|
|
syncFileTreeFilterUi();
|
|
}
|
|
});
|
|
|
|
breadcrumbs.addEventListener("click", (event) => {
|
|
const trigger = event.target.closest("[data-toggle-breadcrumb-overflow]");
|
|
if (trigger) {
|
|
event.preventDefault();
|
|
setBreadcrumbOverflowOpen(trigger.getAttribute("aria-expanded") !== "true");
|
|
return;
|
|
}
|
|
if (event.target.closest(".breadcrumb-overflow-option")) {
|
|
setBreadcrumbOverflowOpen(false);
|
|
}
|
|
});
|
|
|
|
breadcrumbs.addEventListener("keydown", (event) => {
|
|
const trigger = event.target.closest("[data-toggle-breadcrumb-overflow]");
|
|
const menu = breadcrumbs.querySelector("#breadcrumb-overflow-menu");
|
|
const options = [...(menu?.querySelectorAll('[role="menuitem"]') ?? [])].filter(
|
|
(item) => item.getClientRects().length > 0,
|
|
);
|
|
if (trigger && event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
setBreadcrumbOverflowOpen(true, true);
|
|
return;
|
|
}
|
|
if (event.key === "Escape" && menu && !menu.hidden) {
|
|
event.preventDefault();
|
|
setBreadcrumbOverflowOpen(false);
|
|
breadcrumbs.querySelector("[data-toggle-breadcrumb-overflow]")?.focus();
|
|
return;
|
|
}
|
|
const currentIndex = options.indexOf(document.activeElement);
|
|
if (currentIndex < 0) return;
|
|
let nextIndex = null;
|
|
if (event.key === "ArrowDown") nextIndex = (currentIndex + 1) % options.length;
|
|
if (event.key === "ArrowUp") nextIndex = (currentIndex - 1 + options.length) % options.length;
|
|
if (event.key === "Home") nextIndex = 0;
|
|
if (event.key === "End") nextIndex = options.length - 1;
|
|
if (nextIndex != null) {
|
|
event.preventDefault();
|
|
options[nextIndex]?.focus();
|
|
}
|
|
});
|
|
|
|
accountAvatar.addEventListener("error", () => {
|
|
accountAvatar.hidden = true;
|
|
accountFallback.hidden = false;
|
|
});
|
|
|
|
view.addEventListener("submit", (event) => {
|
|
const form = event.target.closest("[data-pull-query-form]");
|
|
if (!form) return;
|
|
event.preventDefault();
|
|
void commitPullQuery(form.querySelector("#pull-query"));
|
|
});
|
|
|
|
view.addEventListener("input", (event) => {
|
|
if (event.target.matches("[data-trx-search-input]")) {
|
|
TrxPreview.applyFilters(event.target.closest(".trx-preview"));
|
|
return;
|
|
}
|
|
if (event.target.id === "pull-query") {
|
|
syncPullQueryEditor(event.target);
|
|
renderPullQuerySuggestions(event.target);
|
|
return;
|
|
}
|
|
if (event.target.id === "pull-author-filter") {
|
|
renderPullAuthorOptions(event.target.value);
|
|
return;
|
|
}
|
|
if (event.target.id === "artifact-filter") {
|
|
artifactFilters.query = event.target.value;
|
|
renderPullArtifactRows(event.target.value);
|
|
return;
|
|
}
|
|
if (event.target.id === "file-filter" && currentArtifact) {
|
|
renderFileRows(
|
|
currentArtifact.analysis.entries,
|
|
currentArtifact.selectedPath,
|
|
event.target.value,
|
|
);
|
|
}
|
|
});
|
|
|
|
view.addEventListener("click", (event) => {
|
|
const trxView = event.target.closest("[data-trx-view]");
|
|
if (trxView) {
|
|
event.preventDefault();
|
|
const preview = trxView.closest(".trx-preview");
|
|
const viewName = trxView.dataset.trxView;
|
|
TrxPreview.showView(preview, viewName);
|
|
if (viewName === "structured") {
|
|
preview.querySelector("[data-trx-search-input]")?.focus();
|
|
}
|
|
return;
|
|
}
|
|
const trxFilter = event.target.closest("[data-trx-filter]");
|
|
if (trxFilter) {
|
|
event.preventDefault();
|
|
const preview = trxFilter.closest(".trx-preview");
|
|
for (const button of preview.querySelectorAll("[data-trx-filter]")) {
|
|
button.setAttribute("aria-pressed", String(button === trxFilter));
|
|
}
|
|
TrxPreview.applyFilters(preview);
|
|
return;
|
|
}
|
|
const pullQuerySuggestion = event.target.closest("[data-pull-query-suggestion]");
|
|
if (pullQuerySuggestion) {
|
|
event.preventDefault();
|
|
insertPullQuerySuggestion(
|
|
document.getElementById("pull-query"),
|
|
Number(pullQuerySuggestion.dataset.pullQuerySuggestion),
|
|
);
|
|
return;
|
|
}
|
|
if (event.target.id === "pull-query") {
|
|
renderPullQuerySuggestions(event.target);
|
|
return;
|
|
}
|
|
const artifactFilterTrigger = event.target.closest("[data-toggle-artifact-filter]");
|
|
if (artifactFilterTrigger) {
|
|
event.preventDefault();
|
|
const kind = artifactFilterTrigger.dataset.toggleArtifactFilter;
|
|
setArtifactFilterPanelOpen(
|
|
kind,
|
|
artifactFilterTrigger.getAttribute("aria-expanded") !== "true",
|
|
);
|
|
return;
|
|
}
|
|
const artifactFilterClose = event.target.closest("[data-close-artifact-filter]");
|
|
if (artifactFilterClose) {
|
|
event.preventDefault();
|
|
setArtifactFilterPanelOpen(null, false);
|
|
return;
|
|
}
|
|
const artifactStatusOption = event.target.closest("[data-select-artifact-status]");
|
|
if (artifactStatusOption) {
|
|
event.preventDefault();
|
|
artifactFilters.status = artifactStatusOption.dataset.selectArtifactStatus;
|
|
setArtifactFilterPanelOpen(null, false);
|
|
renderArtifactFilterControls();
|
|
renderPullArtifactRows();
|
|
return;
|
|
}
|
|
const artifactRunOption = event.target.closest("[data-select-artifact-run]");
|
|
if (artifactRunOption) {
|
|
event.preventDefault();
|
|
artifactFilters.run = artifactRunOption.dataset.selectArtifactRun;
|
|
setArtifactFilterPanelOpen(null, false);
|
|
renderArtifactFilterControls();
|
|
renderPullArtifactRows();
|
|
return;
|
|
}
|
|
const playerSetting = event.target.closest("[data-player-setting][data-player-value]");
|
|
if (playerSetting) {
|
|
event.preventDefault();
|
|
void updatePlayerPreference(
|
|
playerSetting.dataset.playerSetting,
|
|
playerSetting.dataset.playerValue,
|
|
);
|
|
return;
|
|
}
|
|
const playerStep = event.target.closest("[data-player-step]");
|
|
if (playerStep) {
|
|
event.preventDefault();
|
|
stepPlayerTextSize(playerStep.dataset.playerStep);
|
|
return;
|
|
}
|
|
const signalTrigger = event.target.closest("[data-toggle-pull-signal]");
|
|
if (signalTrigger) {
|
|
event.preventDefault();
|
|
const kind = signalTrigger.dataset.togglePullSignal;
|
|
setPullAuthorPanelOpen(false);
|
|
setPullStatePanelOpen(false);
|
|
setPullSignalPanelOpen(
|
|
kind,
|
|
signalTrigger.getAttribute("aria-expanded") !== "true",
|
|
);
|
|
return;
|
|
}
|
|
const signalClose = event.target.closest("[data-close-pull-signal]");
|
|
if (signalClose) {
|
|
event.preventDefault();
|
|
setPullSignalPanelOpen(null, false);
|
|
return;
|
|
}
|
|
const signalOption = event.target.closest("[data-select-pull-signal]");
|
|
if (signalOption) {
|
|
event.preventDefault();
|
|
const route = parseRoute();
|
|
const pullFilters = {
|
|
artifacts: route.artifacts,
|
|
ci: route.ci,
|
|
[signalOption.dataset.selectPullSignal]: signalOption.dataset.pullSignalValue,
|
|
};
|
|
setPullSignalPanelOpen(null, false);
|
|
void refreshPullTable({
|
|
name: "pulls",
|
|
repository: route.repository,
|
|
state: route.state,
|
|
query: document.getElementById("pull-query")?.value ?? route.query,
|
|
...pullFilters,
|
|
});
|
|
return;
|
|
}
|
|
const authorTrigger = event.target.closest("[data-toggle-pull-author]");
|
|
if (authorTrigger) {
|
|
event.preventDefault();
|
|
setPullStatePanelOpen(false);
|
|
setPullSignalPanelOpen(null, false);
|
|
setPullAuthorPanelOpen(authorTrigger.getAttribute("aria-expanded") !== "true");
|
|
return;
|
|
}
|
|
const authorClose = event.target.closest("[data-close-pull-author]");
|
|
if (authorClose) {
|
|
event.preventDefault();
|
|
setPullAuthorPanelOpen(false);
|
|
return;
|
|
}
|
|
const authorOption = event.target.closest("[data-select-pull-author]");
|
|
if (authorOption) {
|
|
event.preventDefault();
|
|
const route = parseRoute();
|
|
const query = pullQueryForAuthor(
|
|
document.getElementById("pull-query")?.value ?? route.query,
|
|
authorOption.dataset.selectPullAuthor || null,
|
|
);
|
|
setPullAuthorPanelOpen(false);
|
|
void refreshPullTable({
|
|
name: "pulls",
|
|
repository: route.repository,
|
|
state: stateFromPullQuery(query),
|
|
query,
|
|
artifacts: route.artifacts,
|
|
ci: route.ci,
|
|
});
|
|
return;
|
|
}
|
|
const stateTrigger = event.target.closest("[data-toggle-pull-state]");
|
|
if (stateTrigger) {
|
|
event.preventDefault();
|
|
setPullAuthorPanelOpen(false);
|
|
setPullSignalPanelOpen(null, false);
|
|
setPullStatePanelOpen(stateTrigger.getAttribute("aria-expanded") !== "true");
|
|
return;
|
|
}
|
|
const stateClose = event.target.closest("[data-close-pull-state]");
|
|
if (stateClose) {
|
|
event.preventDefault();
|
|
setPullStatePanelOpen(false);
|
|
return;
|
|
}
|
|
const stateOption = event.target.closest("[data-select-pull-state]");
|
|
if (stateOption) {
|
|
event.preventDefault();
|
|
const route = parseRoute();
|
|
const state = stateOption.dataset.selectPullState;
|
|
const query = pullQueryForState(
|
|
document.getElementById("pull-query")?.value ?? route.query,
|
|
state,
|
|
);
|
|
setPullStatePanelOpen(false);
|
|
void refreshPullTable({
|
|
name: "pulls",
|
|
repository: route.repository,
|
|
state,
|
|
query,
|
|
artifacts: route.artifacts,
|
|
ci: route.ci,
|
|
});
|
|
return;
|
|
}
|
|
const accountChoice = event.target.closest("[data-select-account]");
|
|
if (accountChoice) {
|
|
event.preventDefault();
|
|
void selectAccount(accountChoice.dataset.selectAccount, accountChoice);
|
|
return;
|
|
}
|
|
const refreshAccountsButton = event.target.closest("[data-refresh-accounts]");
|
|
if (refreshAccountsButton) {
|
|
event.preventDefault();
|
|
void refreshAccounts(refreshAccountsButton);
|
|
return;
|
|
}
|
|
const fileTreeFilterTrigger = event.target.closest("[data-toggle-file-tree-filter]");
|
|
if (fileTreeFilterTrigger && currentArtifact) {
|
|
event.preventDefault();
|
|
currentArtifact.fileTree.filterMenuOpen =
|
|
!currentArtifact.fileTree.filterMenuOpen;
|
|
syncFileTreeFilterUi();
|
|
return;
|
|
}
|
|
const fileExtensionFilter = event.target.closest("[data-file-tree-extension]");
|
|
if (fileExtensionFilter && currentArtifact) {
|
|
event.preventDefault();
|
|
const extension = fileExtensionFilter.dataset.fileTreeExtension;
|
|
if (currentArtifact.fileTree.excludedExtensions.has(extension)) {
|
|
currentArtifact.fileTree.excludedExtensions.delete(extension);
|
|
} else {
|
|
currentArtifact.fileTree.excludedExtensions.add(extension);
|
|
}
|
|
syncFileTreeFilterUi();
|
|
renderFileRows(
|
|
currentArtifact.analysis.entries,
|
|
currentArtifact.selectedPath,
|
|
document.getElementById("file-filter")?.value ?? "",
|
|
);
|
|
return;
|
|
}
|
|
const fileCategoryFilter = event.target.closest("[data-file-tree-category]");
|
|
if (fileCategoryFilter && currentArtifact) {
|
|
event.preventDefault();
|
|
const category = fileCategoryFilter.dataset.fileTreeCategory;
|
|
if (category === "manifest") {
|
|
currentArtifact.fileTree.onlyManifestFiles =
|
|
!currentArtifact.fileTree.onlyManifestFiles;
|
|
} else if (category === "vendored") {
|
|
currentArtifact.fileTree.includeVendoredFiles =
|
|
!currentArtifact.fileTree.includeVendoredFiles;
|
|
} else if (category === "viewed") {
|
|
currentArtifact.fileTree.includeViewedFiles =
|
|
!currentArtifact.fileTree.includeViewedFiles;
|
|
}
|
|
syncFileTreeFilterUi();
|
|
renderFileRows(
|
|
currentArtifact.analysis.entries,
|
|
currentArtifact.selectedPath,
|
|
document.getElementById("file-filter")?.value ?? "",
|
|
);
|
|
return;
|
|
}
|
|
const directoryButton = event.target.closest("[data-file-directory]");
|
|
if (directoryButton && currentArtifact) {
|
|
event.preventDefault();
|
|
const path = directoryButton.dataset.fileDirectory;
|
|
if (currentArtifact.fileTree.expandedDirectories.has(path)) {
|
|
currentArtifact.fileTree.expandedDirectories.delete(path);
|
|
} else {
|
|
currentArtifact.fileTree.expandedDirectories.add(path);
|
|
}
|
|
renderFileRows(
|
|
currentArtifact.analysis.entries,
|
|
currentArtifact.selectedPath,
|
|
document.getElementById("file-filter")?.value ?? "",
|
|
);
|
|
requestAnimationFrame(() => {
|
|
[...document.querySelectorAll("[data-file-directory]")]
|
|
.find((button) => button.dataset.fileDirectory === path)
|
|
?.focus();
|
|
});
|
|
return;
|
|
}
|
|
const sidebarButton = event.target.closest("[data-toggle-sidebar]");
|
|
if (sidebarButton) {
|
|
event.preventDefault();
|
|
void toggleSidebar();
|
|
return;
|
|
}
|
|
const inspectButton = event.target.closest("[data-inspect-artifact]");
|
|
if (inspectButton) {
|
|
event.preventDefault();
|
|
void inspectArtifactButton(inspectButton);
|
|
return;
|
|
}
|
|
const fileButton = event.target.closest("[data-artifact-file]");
|
|
if (fileButton) {
|
|
event.preventDefault();
|
|
void selectArtifactFile(fileButton.dataset.artifactFile);
|
|
return;
|
|
}
|
|
const deleteButton = event.target.closest("[data-delete-artifact]");
|
|
if (deleteButton) {
|
|
event.preventDefault();
|
|
void deleteArtifact(deleteButton.dataset.deleteArtifact);
|
|
return;
|
|
}
|
|
const clearButton = event.target.closest("[data-clear-cache]");
|
|
if (clearButton) {
|
|
event.preventDefault();
|
|
void clearCache();
|
|
return;
|
|
}
|
|
const refreshButton = event.target.closest("[data-refresh-view]");
|
|
if (refreshButton) {
|
|
event.preventDefault();
|
|
const route = parseRoute();
|
|
if (route.name === "pulls") {
|
|
void refreshPullTable(route, { force: true });
|
|
} else {
|
|
void renderRoute({ force: true });
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
|
|
view.addEventListener("focusin", (event) => {
|
|
if (event.target.id !== "pull-query") return;
|
|
syncPullQueryEditor(event.target);
|
|
renderPullQuerySuggestions(event.target);
|
|
});
|
|
|
|
view.addEventListener("focusout", (event) => {
|
|
if (event.target.id !== "pull-query") return;
|
|
const input = event.target;
|
|
queueMicrotask(() => {
|
|
if (document.activeElement === input) return;
|
|
setPullQuerySuggestionsOpen(input, false);
|
|
void commitPullQuery(input);
|
|
});
|
|
});
|
|
|
|
view.addEventListener(
|
|
"scroll",
|
|
(event) => {
|
|
if (event.target.id === "pull-query") syncPullQueryEditor(event.target);
|
|
},
|
|
true,
|
|
);
|
|
|
|
view.addEventListener("keyup", (event) => {
|
|
if (
|
|
event.target.id === "pull-query" &&
|
|
["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)
|
|
) {
|
|
syncPullQueryEditor(event.target);
|
|
renderPullQuerySuggestions(event.target);
|
|
}
|
|
});
|
|
|
|
view.addEventListener("keydown", (event) => {
|
|
if (event.target.id === "pull-query") {
|
|
const input = event.target;
|
|
const suggestions = document.getElementById("pull-query-suggestions");
|
|
if (event.key === "Escape" && suggestions && !suggestions.hidden) {
|
|
event.preventDefault();
|
|
setPullQuerySuggestionsOpen(input, false);
|
|
return;
|
|
}
|
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
const wasHidden = suggestions?.hidden !== false;
|
|
if (wasHidden) renderPullQuerySuggestions(input);
|
|
if (wasHidden && !document.getElementById("pull-query-suggestions")?.hidden) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if (
|
|
!document.getElementById("pull-query-suggestions")?.hidden &&
|
|
movePullQuerySuggestion(input, event.key === "ArrowDown" ? 1 : -1)
|
|
) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
if (event.key === "Tab" && suggestions && !suggestions.hidden) {
|
|
if (insertPullQuerySuggestion(input)) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
if (event.key === "Enter") {
|
|
setPullQuerySuggestionsOpen(input, false);
|
|
}
|
|
}
|
|
const openFileTreeFilter = document.querySelector(
|
|
'[data-toggle-file-tree-filter][aria-expanded="true"]',
|
|
);
|
|
if (event.key === "Escape" && openFileTreeFilter && currentArtifact) {
|
|
event.preventDefault();
|
|
currentArtifact.fileTree.filterMenuOpen = false;
|
|
syncFileTreeFilterUi();
|
|
openFileTreeFilter.focus();
|
|
return;
|
|
}
|
|
const directoryButton = event.target.closest("[data-file-directory]");
|
|
if (
|
|
directoryButton &&
|
|
currentArtifact &&
|
|
!fileTreeFiltersActive(currentArtifact.fileTree) &&
|
|
!document.getElementById("file-filter")?.value
|
|
) {
|
|
const expanded = directoryButton.getAttribute("aria-expanded") === "true";
|
|
if (
|
|
(event.key === "ArrowRight" && !expanded) ||
|
|
(event.key === "ArrowLeft" && expanded)
|
|
) {
|
|
event.preventDefault();
|
|
directoryButton.click();
|
|
return;
|
|
}
|
|
}
|
|
const openArtifactFilter = document.querySelector(
|
|
'[data-toggle-artifact-filter][aria-expanded="true"]',
|
|
);
|
|
if (event.key === "Escape" && openArtifactFilter) {
|
|
event.preventDefault();
|
|
setArtifactFilterPanelOpen(null, false);
|
|
openArtifactFilter.focus();
|
|
return;
|
|
}
|
|
const openPullSignal = document.querySelector(
|
|
'[data-toggle-pull-signal][aria-expanded="true"]',
|
|
);
|
|
if (event.key === "Escape" && openPullSignal) {
|
|
event.preventDefault();
|
|
setPullSignalPanelOpen(null, false);
|
|
openPullSignal.focus();
|
|
return;
|
|
}
|
|
if (event.key === "Escape" && !document.getElementById("pull-author-panel")?.hidden) {
|
|
event.preventDefault();
|
|
setPullAuthorPanelOpen(false);
|
|
document.querySelector("[data-toggle-pull-author]")?.focus();
|
|
return;
|
|
}
|
|
if (event.key === "Escape" && !document.getElementById("pull-state-panel")?.hidden) {
|
|
event.preventDefault();
|
|
setPullStatePanelOpen(false);
|
|
document.querySelector("[data-toggle-pull-state]")?.focus();
|
|
return;
|
|
}
|
|
});
|
|
|
|
window.addEventListener("hashchange", () => {
|
|
void renderRoute();
|
|
});
|
|
|
|
async function initialize() {
|
|
try {
|
|
installPreviewThemeSync();
|
|
bootstrapState = await api("/api/bootstrap");
|
|
repositoryInput.value = bootstrapState.prefs.repository;
|
|
renderAccounts(bootstrapState.accounts, bootstrapState.account);
|
|
updateCacheHeader(bootstrapState.cache);
|
|
|
|
const events = new EventSource(
|
|
`/events?token=${encodeURIComponent(capabilityToken)}`,
|
|
);
|
|
events.addEventListener("navigate", (event) => {
|
|
const payload = JSON.parse(event.data);
|
|
if (payload.route) location.hash = payload.route;
|
|
});
|
|
events.addEventListener("cache", (event) => {
|
|
updateCacheHeader(JSON.parse(event.data));
|
|
});
|
|
events.addEventListener("artifact-progress", (event) => {
|
|
updateArtifactDownloadProgress(JSON.parse(event.data));
|
|
});
|
|
events.addEventListener("refresh", () => {
|
|
void renderRoute();
|
|
});
|
|
|
|
if (!location.hash || location.hash === "#/") {
|
|
const input = bootstrapState.initialInput ?? {};
|
|
if (typeof input.url === "string" && input.url) {
|
|
location.hash = routeFromInput(input.url);
|
|
return;
|
|
}
|
|
if (typeof input.repository === "string" && input.pullNumber) {
|
|
location.hash = pullHash(input.repository, input.pullNumber);
|
|
return;
|
|
}
|
|
if (typeof input.repository === "string") {
|
|
location.hash = pullsHashForRepository(input.repository);
|
|
return;
|
|
}
|
|
location.hash = pullsHashForRepository(bootstrapState.prefs.repository);
|
|
return;
|
|
}
|
|
await renderRoute();
|
|
} catch (error) {
|
|
setBreadcrumbs([{ label: "PR Artifact Explorer" }]);
|
|
renderError(error, "PR Artifact Explorer could not start");
|
|
}
|
|
}
|
|
|
|
void initialize();
|