mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 10:01:06 +00:00
e9c8e37041
Show "by @handle" on each canvas extension card and in the details
modal, linking to the contributor's GitHub profile. Author metadata
lives in each extension's canvas.json (and external.json for external
extensions), where the rest of the canvas metadata is stored.
- Store author {name, url} in canvas.json / external.json
- Read author from canvas.json in the website data generator and emit
it to extensions.json
- Render the GitHub @handle, derived from the profile URL, as the link
text, with the contributor's name as the link title
- Escape the sanitized author URL before interpolating it into href
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
764 lines
24 KiB
TypeScript
764 lines
24 KiB
TypeScript
import { getEmbeddedData as getEmbeddedPageData } from "./embedded-data";
|
|
|
|
/**
|
|
* Utility functions for the Awesome Copilot website
|
|
*/
|
|
|
|
const REPO_BASE_URL =
|
|
"https://raw.githubusercontent.com/github/awesome-copilot/main";
|
|
const REPO_GITHUB_URL = "https://github.com/github/awesome-copilot/blob/main";
|
|
|
|
/**
|
|
* The GitHub repo identifier used for `gh skills install` commands
|
|
*/
|
|
export const REPO_IDENTIFIER = "github/awesome-copilot";
|
|
|
|
// VS Code install URL configurations
|
|
const VSCODE_INSTALL_CONFIG: Record<
|
|
string,
|
|
{ baseUrl: string; scheme: string }
|
|
> = {
|
|
instructions: {
|
|
baseUrl: "https://aka.ms/awesome-copilot/install/instructions",
|
|
scheme: "chat-instructions",
|
|
},
|
|
instruction: {
|
|
baseUrl: "https://aka.ms/awesome-copilot/install/instructions",
|
|
scheme: "chat-instructions",
|
|
},
|
|
agent: {
|
|
baseUrl: "https://aka.ms/awesome-copilot/install/agent",
|
|
scheme: "chat-agent",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Get the base path for the site
|
|
*/
|
|
export function getBasePath(): string {
|
|
// In Astro, import.meta.env.BASE_URL is available at build time
|
|
// At runtime, we use a data attribute on the body
|
|
if (typeof document !== "undefined") {
|
|
return document.body.dataset.basePath || "/";
|
|
}
|
|
return "/";
|
|
}
|
|
|
|
/**
|
|
* Fetch JSON data from the data directory
|
|
*/
|
|
export async function fetchData<T = unknown>(
|
|
filename: string
|
|
): Promise<T | null> {
|
|
const embeddedData = getEmbeddedPageData<T>(filename);
|
|
if (embeddedData !== null) return embeddedData;
|
|
|
|
try {
|
|
const basePath = getBasePath();
|
|
const response = await fetch(`${basePath}data/${filename}`);
|
|
if (!response.ok) throw new Error(`Failed to fetch ${filename}`);
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error(`Error fetching ${filename}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
let jsZipPromise: Promise<typeof import("./jszip")> | null = null;
|
|
|
|
/**
|
|
* Lazy-load JSZip only when downloads are requested
|
|
*/
|
|
export async function loadJSZip() {
|
|
jsZipPromise ??= import("./jszip");
|
|
const { default: JSZip } = await jsZipPromise;
|
|
return JSZip;
|
|
}
|
|
|
|
export interface ZipDownloadFile {
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
function triggerBlobDownload(blob: Blob, filename: string): void {
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export async function downloadZipBundle(
|
|
bundleName: string,
|
|
files: ZipDownloadFile[]
|
|
): Promise<void> {
|
|
if (files.length === 0) {
|
|
throw new Error("No files found for this download.");
|
|
}
|
|
|
|
const JSZip = await loadJSZip();
|
|
const zip = new JSZip();
|
|
const folder = zip.folder(bundleName);
|
|
|
|
const fetchPromises = files.map(async (file) => {
|
|
try {
|
|
const response = await fetch(getRawGitHubUrl(file.path));
|
|
if (!response.ok) return null;
|
|
|
|
return {
|
|
name: file.name,
|
|
content: await response.text(),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const results = await Promise.all(fetchPromises);
|
|
let addedFiles = 0;
|
|
|
|
for (const result of results) {
|
|
if (result && folder) {
|
|
folder.file(result.name, result.content);
|
|
addedFiles++;
|
|
}
|
|
}
|
|
|
|
if (addedFiles === 0) {
|
|
throw new Error("Failed to fetch any files");
|
|
}
|
|
|
|
const blob = await zip.generateAsync({ type: "blob" });
|
|
triggerBlobDownload(blob, `${bundleName}.zip`);
|
|
}
|
|
|
|
/**
|
|
* Fetch raw file content from GitHub
|
|
*/
|
|
export async function fetchFileContent(
|
|
filePath: string
|
|
): Promise<string | null> {
|
|
try {
|
|
const response = await fetch(`${REPO_BASE_URL}/${filePath}`);
|
|
if (!response.ok) throw new Error(`Failed to fetch ${filePath}`);
|
|
return await response.text();
|
|
} catch (error) {
|
|
console.error(`Error fetching file content:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copy text to clipboard
|
|
*/
|
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
return true;
|
|
} catch {
|
|
// Deprecated fallback for older browsers that lack the async clipboard API.
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = text;
|
|
textarea.style.position = "fixed";
|
|
textarea.style.opacity = "0";
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
const success = document.execCommand("copy");
|
|
document.body.removeChild(textarea);
|
|
return success;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate VS Code install URL
|
|
* @param type - Resource type (agent, instructions)
|
|
* @param filePath - Path to the file
|
|
* @param insiders - Whether to use VS Code Insiders
|
|
*/
|
|
export function getVSCodeInstallUrl(
|
|
type: string,
|
|
filePath: string,
|
|
insiders = false
|
|
): string | null {
|
|
const config = VSCODE_INSTALL_CONFIG[type];
|
|
if (!config) return null;
|
|
|
|
const rawUrl = `${REPO_BASE_URL}/${filePath}`;
|
|
const vscodeScheme = insiders ? "vscode-insiders" : "vscode";
|
|
const innerUrl = `${vscodeScheme}:${
|
|
config.scheme
|
|
}/install?url=${encodeURIComponent(rawUrl)}`;
|
|
|
|
return `${config.baseUrl}?url=${encodeURIComponent(innerUrl)}`;
|
|
}
|
|
|
|
/**
|
|
* Get GitHub URL for a file
|
|
*/
|
|
export function getGitHubUrl(filePath: string): string {
|
|
return `${REPO_GITHUB_URL}/${filePath}`;
|
|
}
|
|
|
|
/**
|
|
* Get raw GitHub URL for a file (for fetching content)
|
|
*/
|
|
export function getRawGitHubUrl(filePath: string): string {
|
|
return `${REPO_BASE_URL}/${filePath}`;
|
|
}
|
|
|
|
/**
|
|
* Download a file from its path
|
|
*/
|
|
export async function downloadFile(filePath: string): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(`${REPO_BASE_URL}/${filePath}`);
|
|
if (!response.ok) throw new Error("Failed to fetch file");
|
|
|
|
const content = await response.text();
|
|
const filename = filePath.split("/").pop() || "file.md";
|
|
|
|
const blob = new Blob([content], { type: "text/markdown" });
|
|
triggerBlobDownload(blob, filename);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Download failed:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Share/copy link to clipboard (deep link to current page with file hash)
|
|
*/
|
|
export async function shareFile(filePath: string): Promise<boolean> {
|
|
const deepLinkUrl = `${window.location.origin}${
|
|
window.location.pathname
|
|
}#file=${encodeURIComponent(filePath)}`;
|
|
return copyToClipboard(deepLinkUrl);
|
|
}
|
|
|
|
type QueryParamValue = string | string[] | boolean | null | undefined;
|
|
|
|
/**
|
|
* Read a single query parameter.
|
|
*/
|
|
export function getQueryParam(name: string): string {
|
|
if (typeof window === "undefined") return "";
|
|
return new URLSearchParams(window.location.search).get(name)?.trim() ?? "";
|
|
}
|
|
|
|
/**
|
|
* Read repeated query parameter values.
|
|
*/
|
|
export function getQueryParamValues(name: string): string[] {
|
|
if (typeof window === "undefined") return [];
|
|
const values = new URLSearchParams(window.location.search)
|
|
.getAll(name)
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
return Array.from(new Set(values));
|
|
}
|
|
|
|
/**
|
|
* Read a boolean-style query parameter.
|
|
*/
|
|
export function getQueryParamFlag(name: string): boolean {
|
|
const value = getQueryParam(name).toLowerCase();
|
|
return value === "1" || value === "true" || value === "yes";
|
|
}
|
|
|
|
/**
|
|
* Update query parameters while preserving the current hash.
|
|
*/
|
|
export function updateQueryParams(
|
|
updates: Record<string, QueryParamValue>
|
|
): void {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const url = new URL(window.location.href);
|
|
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
url.searchParams.delete(key);
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const normalized = item.trim();
|
|
if (normalized) {
|
|
url.searchParams.append(key, normalized);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (typeof value === "boolean") {
|
|
if (value) {
|
|
url.searchParams.set(key, "1");
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
const normalized = value.trim();
|
|
if (normalized) {
|
|
url.searchParams.set(key, normalized);
|
|
}
|
|
}
|
|
}
|
|
|
|
const search = url.searchParams.toString();
|
|
const nextUrl = `${url.pathname}${search ? `?${search}` : ""}${url.hash}`;
|
|
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
|
|
if (nextUrl !== currentUrl) {
|
|
history.replaceState(null, "", nextUrl);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show a toast notification
|
|
*/
|
|
export function showToast(
|
|
message: string,
|
|
type: "success" | "error" = "success"
|
|
): void {
|
|
const existing = document.querySelector(".toast");
|
|
if (existing) existing.remove();
|
|
|
|
const toast = document.createElement("div");
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 3000);
|
|
}
|
|
|
|
/**
|
|
* Debounce function for search input
|
|
*/
|
|
export function debounce<T extends (...args: unknown[]) => void>(
|
|
func: T,
|
|
wait: number
|
|
): (...args: Parameters<T>) => void {
|
|
let timeout: ReturnType<typeof setTimeout>;
|
|
return function executedFunction(...args: Parameters<T>) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
*/
|
|
export function escapeHtml(text: string | string[]): string {
|
|
if (Array.isArray(text)) {
|
|
return text.map(escapeHtml).join(", ");
|
|
}
|
|
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* Validate and sanitize URLs to prevent XSS attacks
|
|
* Only allows http/https protocols, returns '#' for invalid URLs
|
|
*/
|
|
export function sanitizeUrl(url: string | null | undefined): string {
|
|
if (!url) return "#";
|
|
try {
|
|
const parsed = new URL(url);
|
|
// Only allow http and https protocols
|
|
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
return url;
|
|
}
|
|
} catch {
|
|
// Invalid URL
|
|
}
|
|
return "#";
|
|
}
|
|
|
|
/**
|
|
* Derive a GitHub @handle from a profile URL
|
|
* (e.g. "https://github.com/aaronpowell" -> "@aaronpowell").
|
|
* Falls back to the provided value when the URL is not a github.com profile.
|
|
*/
|
|
export function getGitHubHandle(
|
|
url: string | null | undefined,
|
|
fallback = ""
|
|
): string {
|
|
if (!url) return fallback;
|
|
try {
|
|
const parsed = new URL(url);
|
|
const host = parsed.hostname.replace(/^www\./, "");
|
|
if (host === "github.com") {
|
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
if (segments.length === 1) {
|
|
return `@${segments[0]}`;
|
|
}
|
|
}
|
|
} catch {
|
|
// Invalid URL
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/**
|
|
* Truncate text with ellipsis
|
|
*/
|
|
export function truncate(text: string | undefined, maxLength: number): string {
|
|
if (!text || text.length <= maxLength) return text || "";
|
|
return text.slice(0, maxLength).trim() + "...";
|
|
}
|
|
|
|
/**
|
|
* Get resource type from file path
|
|
*/
|
|
export function getResourceType(filePath: string): string {
|
|
if (filePath.endsWith(".agent.md")) return "agent";
|
|
if (filePath.endsWith(".instructions.md")) return "instruction";
|
|
if (/(^|\/)skills\//.test(filePath)) return "skill";
|
|
if (/(^|\/)hooks\//.test(filePath)) return "hook";
|
|
if (/(^|\/)workflows\//.test(filePath) && filePath.endsWith(".md"))
|
|
return "workflow";
|
|
// Check for plugin directories (e.g., plugins/<id>, plugins/<id>/)
|
|
if (/(^|\/)plugins\/[^/]+\/?$/.test(filePath)) return "plugin";
|
|
// Check for plugin.json files (e.g., plugins/<id>/.github/plugin/plugin.json)
|
|
if (filePath.endsWith("/.github/plugin/plugin.json")) return "plugin";
|
|
return "unknown";
|
|
}
|
|
|
|
/**
|
|
* Format a resource type for display
|
|
*/
|
|
export function formatResourceType(type: string): string {
|
|
const labels: Record<string, string> = {
|
|
agent: "🤖 Agent",
|
|
instruction: "📋 Instruction",
|
|
skill: "⚡ Skill",
|
|
hook: "🪝 Hook",
|
|
workflow: "⚡ Workflow",
|
|
plugin: "🔌 Plugin",
|
|
};
|
|
return labels[type] || type;
|
|
}
|
|
|
|
/**
|
|
* Get icon for resource type (returns SVG icon name)
|
|
*/
|
|
export function getResourceIcon(type: string): string {
|
|
const icons: Record<string, string> = {
|
|
agent: "robot",
|
|
instruction: "document",
|
|
skill: "lightning",
|
|
hook: "hook",
|
|
workflow: "workflow",
|
|
plugin: "plug",
|
|
};
|
|
return icons[type] || "document";
|
|
}
|
|
|
|
// Icon definitions with fill/stroke type info
|
|
const iconDefs: Record<string, { path: string; fill?: boolean }> = {
|
|
// Agent icon - GitHub Primer's agent-24
|
|
robot: {
|
|
fill: true,
|
|
path: '<path d="M22.5 13.919v-.278a5.097 5.097 0 0 0-4.961-5.086.858.858 0 0 1-.754-.497l-.149-.327A6.414 6.414 0 0 0 10.81 4a6.133 6.133 0 0 0-6.13 6.32l.019.628a.863.863 0 0 1-.67.869A3.263 3.263 0 0 0 1.5 14.996v.108A3.397 3.397 0 0 0 4.896 18.5h1.577a.75.75 0 0 1 0 1.5H4.896A4.896 4.896 0 0 1 0 15.104v-.108a4.761 4.761 0 0 1 3.185-4.493l-.004-.137A7.633 7.633 0 0 1 10.81 2.5a7.911 7.911 0 0 1 7.176 4.58C21.36 7.377 24 10.207 24 13.641v.278a.75.75 0 0 1-1.5 0Z"/><path d="m12.306 11.77 3.374 3.375a.749.749 0 0 1 0 1.061l-3.375 3.375-.057.051a.751.751 0 0 1-1.004-.051.751.751 0 0 1-.051-1.004l.051-.057 2.845-2.845-2.844-2.844a.75.75 0 1 1 1.061-1.061ZM22.5 19.8H18a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 0 1.5Z"/>',
|
|
},
|
|
document: {
|
|
path: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
},
|
|
lightning: {
|
|
path: '<path d="M13 2 4.09 12.11a1.23 1.23 0 0 0 .13 1.72l.16.14a1.23 1.23 0 0 0 1.52 0L13 9.5V22l8.91-10.11a1.23 1.23 0 0 0-.13-1.72l-.16-.14a1.23 1.23 0 0 0-1.52 0L13 14.5V2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
},
|
|
// Hook icon - GitHub Primer's sync-24
|
|
hook: {
|
|
fill: true,
|
|
path: '<path d="M3.38 8A9.502 9.502 0 0 1 12 2.5a9.502 9.502 0 0 1 9.215 7.182.75.75 0 1 0 1.456-.364C21.473 4.539 17.15 1 12 1a10.995 10.995 0 0 0-9.5 5.452V4.75a.75.75 0 0 0-1.5 0V8.5a1 1 0 0 0 1 1h3.75a.75.75 0 0 0 0-1.5H3.38Zm-.595 6.318a.75.75 0 0 0-1.455.364C2.527 19.461 6.85 23 12 23c4.052 0 7.592-2.191 9.5-5.451v1.701a.75.75 0 0 0 1.5 0V15.5a1 1 0 0 0-1-1h-3.75a.75.75 0 0 0 0 1.5h2.37A9.502 9.502 0 0 1 12 21.5c-4.446 0-8.181-3.055-9.215-7.182Z"/>',
|
|
},
|
|
// Workflow icon - GitHub Primer's workflow-24
|
|
workflow: {
|
|
fill: true,
|
|
path: '<path d="M1 3a2 2 0 0 1 2-2h6.5a2 2 0 0 1 2 2v6.5a2 2 0 0 1-2 2H7v4.063C7 16.355 7.644 17 8.438 17H12.5v-2.5a2 2 0 0 1 2-2H21a2 2 0 0 1 2 2V21a2 2 0 0 1-2 2h-6.5a2 2 0 0 1-2-2v-2.5H8.437A2.939 2.939 0 0 1 5.5 15.562V11.5H3a2 2 0 0 1-2-2Zm2-.5a.5.5 0 0 0-.5.5v6.5a.5.5 0 0 0 .5.5h6.5a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5ZM14.5 14a.5.5 0 0 0-.5.5V21a.5.5 0 0 0 .5.5H21a.5.5 0 0 0 .5-.5v-6.5a.5.5 0 0 0-.5-.5Z"/>',
|
|
},
|
|
// Plug icon - GitHub Primer's plug-24
|
|
plug: {
|
|
fill: true,
|
|
path: '<path d="M7 11.5H2.938c-.794 0-1.438.644-1.438 1.437v8.313a.75.75 0 0 1-1.5 0v-8.312A2.939 2.939 0 0 1 2.937 10H7V6.151c0-.897.678-1.648 1.57-1.74l6.055-.626 1.006-1.174A1.752 1.752 0 0 1 16.96 2h1.29c.966 0 1.75.784 1.75 1.75V6h3.25a.75.75 0 0 1 0 1.5H20V14h3.25a.75.75 0 0 1 0 1.5H20v2.25a1.75 1.75 0 0 1-1.75 1.75h-1.29a1.75 1.75 0 0 1-1.329-.611l-1.006-1.174-6.055-.627A1.749 1.749 0 0 1 7 15.348Zm9.77-7.913v.001l-1.201 1.4a.75.75 0 0 1-.492.258l-6.353.657a.25.25 0 0 0-.224.249v9.196a.25.25 0 0 0 .224.249l6.353.657c.191.02.368.112.493.258l1.2 1.401a.252.252 0 0 0 .19.087h1.29a.25.25 0 0 0 .25-.25v-14a.25.25 0 0 0-.25-.25h-1.29a.252.252 0 0 0-.19.087Z"/>',
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Get SVG icon HTML for resource type
|
|
*/
|
|
export function getResourceIconSvg(type: string, size = 20): string {
|
|
const iconName = getResourceIcon(type);
|
|
const icon = iconDefs[iconName] || iconDefs.document;
|
|
const fill = icon.fill ? 'fill="currentColor"' : 'fill="none"';
|
|
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" ${fill} aria-hidden="true">${icon.path}</svg>`;
|
|
}
|
|
|
|
/**
|
|
* Generate HTML for install dropdown button
|
|
*/
|
|
export function getInstallDropdownHtml(
|
|
type: string,
|
|
filePath: string,
|
|
small = false
|
|
): string {
|
|
const vscodeUrl = getVSCodeInstallUrl(type, filePath, false);
|
|
const insidersUrl = getVSCodeInstallUrl(type, filePath, true);
|
|
|
|
if (!vscodeUrl) return "";
|
|
|
|
const sizeClass = small ? "install-dropdown-small" : "";
|
|
const uniqueId = `install-${filePath.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
|
|
|
return `
|
|
<div class="install-dropdown ${sizeClass}" id="${uniqueId}" data-install-scope="list">
|
|
<a href="${vscodeUrl}" class="btn btn-primary ${
|
|
small ? "btn-small" : ""
|
|
} install-btn-main" target="_blank" rel="noopener">
|
|
Install
|
|
</a>
|
|
<button type="button" class="btn btn-primary ${
|
|
small ? "btn-small" : ""
|
|
} install-btn-toggle" aria-label="Install options" aria-expanded="false">
|
|
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
|
|
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>
|
|
</svg>
|
|
</button>
|
|
<div class="install-dropdown-menu">
|
|
<a href="${vscodeUrl}" target="_blank" rel="noopener">
|
|
VS Code
|
|
</a>
|
|
<a href="${insidersUrl}" target="_blank" rel="noopener">
|
|
VS Code Insiders
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Setup dropdown close handlers for dynamically created dropdowns
|
|
*/
|
|
export function setupDropdownCloseHandlers(): void {
|
|
if (dropdownHandlersReady) return;
|
|
dropdownHandlersReady = true;
|
|
|
|
document.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
const target = e.target as HTMLElement;
|
|
const dropdown = target.closest(
|
|
'.install-dropdown[data-install-scope="list"]'
|
|
);
|
|
const toggle = target.closest(
|
|
".install-btn-toggle"
|
|
) as HTMLButtonElement | null;
|
|
const menuLink = target.closest(
|
|
".install-dropdown-menu a"
|
|
) as HTMLAnchorElement | null;
|
|
|
|
if (dropdown) {
|
|
e.stopPropagation();
|
|
|
|
if (toggle) {
|
|
e.preventDefault();
|
|
const isOpen = dropdown.classList.toggle("open");
|
|
toggle.setAttribute("aria-expanded", String(isOpen));
|
|
|
|
if (isOpen) {
|
|
document
|
|
.querySelectorAll('.install-dropdown[data-install-scope="list"].open')
|
|
.forEach((openDropdown) => {
|
|
if (openDropdown === dropdown) return;
|
|
openDropdown.classList.remove("open");
|
|
openDropdown.querySelector(".install-btn-toggle")
|
|
?.setAttribute("aria-expanded", "false");
|
|
});
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (menuLink) {
|
|
dropdown.classList.remove("open");
|
|
const toggleBtn = dropdown.querySelector<HTMLButtonElement>(
|
|
".install-btn-toggle"
|
|
);
|
|
toggleBtn?.setAttribute("aria-expanded", "false");
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
document
|
|
.querySelectorAll('.install-dropdown[data-install-scope="list"].open')
|
|
.forEach((openDropdown) => {
|
|
openDropdown.classList.remove("open");
|
|
const toggleBtn = openDropdown.querySelector<HTMLButtonElement>(
|
|
".install-btn-toggle"
|
|
);
|
|
toggleBtn?.setAttribute("aria-expanded", "false");
|
|
});
|
|
},
|
|
true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generate HTML for action buttons (download, share) in list view
|
|
*/
|
|
export function getActionButtonsHtml(filePath: string, small = false): string {
|
|
const btnClass = small ? "btn-small" : "";
|
|
const iconSize = small ? 14 : 16;
|
|
|
|
return `
|
|
<button class="btn btn-secondary ${btnClass} action-download" data-path="${escapeHtml(
|
|
filePath
|
|
)}" title="Download file">
|
|
<svg viewBox="0 0 16 16" width="${iconSize}" height="${iconSize}" fill="currentColor">
|
|
<path d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06l3.75 3.75ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z"/>
|
|
</svg>
|
|
</button>
|
|
<button class="btn btn-secondary ${btnClass} action-share" data-path="${escapeHtml(
|
|
filePath
|
|
)}" title="Copy link">
|
|
<svg viewBox="0 0 16 16" width="${iconSize}" height="${iconSize}" fill="currentColor">
|
|
<path d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25zm-.025 5.45a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 1 1-2.83-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25z"/>
|
|
</svg>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Setup global action handlers for download and share buttons
|
|
*/
|
|
export function setupActionHandlers(): void {
|
|
if (actionHandlersReady) return;
|
|
actionHandlersReady = true;
|
|
|
|
document.addEventListener(
|
|
"click",
|
|
async (e) => {
|
|
const target = (e.target as HTMLElement).closest(
|
|
".action-download, .action-share"
|
|
) as HTMLElement | null;
|
|
if (!target) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const path = target.dataset.path;
|
|
if (!path) return;
|
|
|
|
if (target.classList.contains("action-download")) {
|
|
const success = await downloadFile(path);
|
|
showToast(
|
|
success ? "Download started!" : "Download failed",
|
|
success ? "success" : "error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const success = await shareFile(path);
|
|
showToast(
|
|
success ? "Link copied!" : "Failed to copy link",
|
|
success ? "success" : "error"
|
|
);
|
|
},
|
|
true
|
|
);
|
|
}
|
|
|
|
let dropdownHandlersReady = false;
|
|
let actionHandlersReady = false;
|
|
|
|
/**
|
|
* Format a date as relative time (e.g., "3 days ago")
|
|
* @param isoDate - ISO 8601 date string
|
|
* @returns Relative time string
|
|
*/
|
|
export function formatRelativeTime(isoDate: string | null | undefined): string {
|
|
if (!isoDate) return "Unknown";
|
|
|
|
const date = new Date(isoDate);
|
|
if (isNaN(date.getTime())) return "Unknown";
|
|
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffSeconds = Math.floor(diffMs / 1000);
|
|
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
const diffHours = Math.floor(diffMinutes / 60);
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
const diffWeeks = Math.floor(diffDays / 7);
|
|
const diffMonths = Math.floor(diffDays / 30);
|
|
const diffYears = Math.floor(diffDays / 365);
|
|
|
|
if (diffDays === 0) {
|
|
if (diffHours === 0) {
|
|
if (diffMinutes === 0) return "just now";
|
|
return diffMinutes === 1 ? "1 minute ago" : `${diffMinutes} minutes ago`;
|
|
}
|
|
return diffHours === 1 ? "1 hour ago" : `${diffHours} hours ago`;
|
|
}
|
|
if (diffDays === 1) return "yesterday";
|
|
if (diffDays < 7) return `${diffDays} days ago`;
|
|
if (diffWeeks === 1) return "1 week ago";
|
|
if (diffWeeks < 4) return `${diffWeeks} weeks ago`;
|
|
if (diffMonths === 1) return "1 month ago";
|
|
if (diffMonths < 12) return `${diffMonths} months ago`;
|
|
if (diffYears === 1) return "1 year ago";
|
|
return `${diffYears} years ago`;
|
|
}
|
|
|
|
/**
|
|
* Format a date for display (e.g., "January 15, 2026")
|
|
* @param isoDate - ISO 8601 date string
|
|
* @returns Formatted date string
|
|
*/
|
|
export function formatFullDate(isoDate: string | null | undefined): string {
|
|
if (!isoDate) return "Unknown";
|
|
|
|
const date = new Date(isoDate);
|
|
if (isNaN(date.getTime())) return "Unknown";
|
|
|
|
return date.toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate HTML for displaying last updated time with hover tooltip
|
|
* @param isoDate - ISO 8601 date string
|
|
* @returns HTML string with relative time and title attribute
|
|
*/
|
|
export function getLastUpdatedHtml(isoDate: string | null | undefined): string {
|
|
const relativeTime = formatRelativeTime(isoDate);
|
|
const fullDate = formatFullDate(isoDate);
|
|
|
|
if (relativeTime === "Unknown") {
|
|
return `<span class="last-updated">Updated: Unknown</span>`;
|
|
}
|
|
|
|
return `<span class="last-updated" title="${escapeHtml(
|
|
fullDate
|
|
)}">Updated ${relativeTime}</span>`;
|
|
}
|