chore: publish from staged

This commit is contained in:
github-actions[bot]
2026-06-17 05:28:37 +00:00
parent 97205f47ef
commit 2512d7d728
29 changed files with 923 additions and 89 deletions
@@ -42,7 +42,8 @@ jobs:
if (parts[0] === EXTENSIONS_DIR && parts.length >= 2) {
const extName = parts[1];
// Skip the external-assets directory — it's not a canvas extension
if (extName !== EXTERNAL_ASSETS_DIR) {
// Also skip external.json and other files at extensions root level
if (extName !== EXTERNAL_ASSETS_DIR && !extName.includes('.')) {
changedExtDirs.add(path.join(EXTENSIONS_DIR, extName));
}
}
+452 -69
View File
@@ -674,11 +674,33 @@ function generatePluginsData(gitDates) {
/**
* Generate canvas extensions metadata
*/
function getExtensionAssetInfo(extensionDir, relPath, ref) {
function getImageMimeType(filePath) {
const extension = path.extname(filePath).toLowerCase();
const mimeByExtension = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
};
return mimeByExtension[extension] || "application/octet-stream";
}
function resolveImageUrl(value, ref) {
const normalized = normalizeText(value);
if (!normalized) return null;
if (/^https?:\/\//i.test(normalized)) {
return normalized;
}
const repoPath = normalized.replace(/\\/g, "/").replace(/^\/+/, "");
return buildRepoImageUrl(repoPath, ref);
}
function getImageAssetFiles(extensionDir) {
const assetDir = path.join(extensionDir, "assets");
if (!fs.existsSync(assetDir)) {
return null;
return [];
}
const imageExtensions = new Set([
@@ -689,7 +711,35 @@ function getExtensionAssetInfo(extensionDir, relPath, ref) {
".gif",
]);
const preferredNames = [
return fs
.readdirSync(assetDir)
.filter((file) => imageExtensions.has(path.extname(file).toLowerCase()))
.sort((a, b) => a.localeCompare(b));
}
function pickAssetFile(files, preferredNames) {
const preferredLookup = new Set(preferredNames.map((name) => name.toLowerCase()));
for (const file of files) {
if (preferredLookup.has(file.toLowerCase())) {
return file;
}
}
return files[0] || null;
}
function getExtensionAssetInfo(extensionDir, relPath, ref) {
const files = getImageAssetFiles(extensionDir);
if (files.length === 0) {
return null;
}
const iconAsset = pickAssetFile(files, [
"icon.png",
"icon.jpg",
"icon.jpeg",
"icon.webp",
"icon.gif",
"preview.png",
"preview.jpg",
"preview.jpeg",
@@ -705,34 +755,52 @@ function getExtensionAssetInfo(extensionDir, relPath, ref) {
"image.jpeg",
"image.webp",
"image.gif",
];
]);
const galleryAsset = pickAssetFile(files, [
"gallery.png",
"gallery.jpg",
"gallery.jpeg",
"gallery.webp",
"gallery.gif",
"preview.png",
"preview.jpg",
"preview.jpeg",
"preview.webp",
"preview.gif",
"screenshot.png",
"screenshot.jpg",
"screenshot.jpeg",
"screenshot.webp",
"screenshot.gif",
"image.png",
"image.jpg",
"image.jpeg",
"image.webp",
"image.gif",
]);
for (const candidate of preferredNames) {
const candidatePath = path.join(assetDir, candidate);
if (fs.existsSync(candidatePath)) {
const assetPath = `${relPath}/assets/${candidate}`;
return {
assetPath,
imageUrl: buildRepoImageUrl(assetPath, ref),
};
}
}
const files = fs
.readdirSync(assetDir)
.filter((file) => imageExtensions.has(path.extname(file).toLowerCase()))
.sort((a, b) => a.localeCompare(b));
if (files.length === 0) {
return null;
}
const assetFile = files[0];
const assetPath = `${relPath}/assets/${assetFile}`;
const iconFile = iconAsset || galleryAsset;
const galleryFile = galleryAsset || iconAsset;
const iconPath = iconFile ? `${relPath}/assets/${iconFile}` : null;
const galleryPath = galleryFile ? `${relPath}/assets/${galleryFile}` : null;
return {
assetPath,
imageUrl: buildRepoImageUrl(assetPath, ref),
screenshots: {
icon: iconPath
? {
path: iconPath,
type: getImageMimeType(iconPath),
}
: null,
gallery: galleryPath
? {
path: galleryPath,
type: getImageMimeType(galleryPath),
}
: null,
},
assetPath: iconPath,
imageUrl: iconPath ? buildRepoImageUrl(iconPath, ref) : null,
};
}
@@ -744,11 +812,174 @@ function buildRepoImageUrl(assetPath, ref) {
return `https://raw.githubusercontent.com/github/awesome-copilot/${ref}/${encodedAssetPath}`;
}
function generateExtensionsData(gitDates, commitSha) {
const extensions = [];
function extractCanvasMetadataFromSource(source) {
const constants = new Map();
const constantPattern =
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|`([^`$]*)`)\s*;/g;
let constantMatch = constantPattern.exec(source);
while (constantMatch) {
const key = constantMatch[1];
const value = constantMatch[2] ?? constantMatch[3] ?? constantMatch[4] ?? "";
constants.set(key, value.replace(/\\n/g, "\n").trim());
constantMatch = constantPattern.exec(source);
}
function resolveExpression(expr) {
const trimmed = normalizeText(expr);
if (!trimmed) return null;
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed
.slice(1, -1)
.replace(/\\n/g, "\n")
.replace(/\\"/g, '"')
.replace(/\\'/g, "'");
}
if (trimmed.startsWith("`") && trimmed.endsWith("`") && !trimmed.includes("${")) {
return trimmed.slice(1, -1);
}
return constants.get(trimmed) || null;
}
function findMatchingBrace(startIndex) {
let depth = 0;
let inSingle = false;
let inDouble = false;
let inTemplate = false;
let escaped = false;
for (let i = startIndex; i < source.length; i++) {
const char = source[i];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (!inDouble && !inTemplate && char === "'" && !inSingle) {
inSingle = true;
continue;
}
if (inSingle && char === "'") {
inSingle = false;
continue;
}
if (!inSingle && !inTemplate && char === '"' && !inDouble) {
inDouble = true;
continue;
}
if (inDouble && char === '"') {
inDouble = false;
continue;
}
if (!inSingle && !inDouble && char === "`" && !inTemplate) {
inTemplate = true;
continue;
}
if (inTemplate && char === "`") {
inTemplate = false;
continue;
}
if (inSingle || inDouble || inTemplate) {
continue;
}
if (char === "{") depth++;
if (char === "}") {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
function readProp(head, key) {
const pattern = new RegExp(`\\b${key}\\s*:\\s*([^,\\n]+)`);
const match = pattern.exec(head);
return resolveExpression(match?.[1]);
}
const canvases = [];
let cursor = 0;
while (cursor < source.length) {
const createCanvasIndex = source.indexOf("createCanvas(", cursor);
if (createCanvasIndex === -1) {
break;
}
const objectStart = source.indexOf("{", createCanvasIndex);
if (objectStart === -1) {
break;
}
const objectEnd = findMatchingBrace(objectStart);
if (objectEnd === -1) {
break;
}
const objectContent = source.slice(objectStart + 1, objectEnd);
const header = objectContent.slice(0, 1400);
const id = readProp(header, "id");
const displayName = readProp(header, "displayName");
const description = readProp(header, "description");
if (id || displayName || description) {
canvases.push({
id: id || null,
displayName: displayName || null,
description: description || null,
});
}
cursor = objectEnd + 1;
}
return canvases;
}
function getExtensionCanvasFiles(extensionDir) {
const queue = [extensionDir];
const files = [];
while (queue.length > 0) {
const currentDir = queue.shift();
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
queue.push(absolutePath);
} else if (entry.isFile() && entry.name.endsWith(".mjs")) {
files.push(absolutePath);
}
}
}
return files.sort((a, b) => a.localeCompare(b));
}
function normalizeExternalScreenshotRole(value, ref) {
if (!value) return null;
if (typeof value === "string") {
const type = getImageMimeType(value);
return {
path: value.replace(/\\/g, "/"),
type,
imageUrl: resolveImageUrl(value, ref),
};
}
const pathValue = normalizeText(value.path);
const urlValue = normalizeText(value.url);
if (!pathValue && !urlValue) return null;
const imagePath = pathValue ? pathValue.replace(/\\/g, "/") : null;
const type = normalizeText(value.type) || getImageMimeType(imagePath || urlValue);
const imageUrl = resolveImageUrl(urlValue || imagePath, ref);
return {
path: imagePath,
type,
imageUrl,
};
}
function generateCanvasManifest(gitDates, commitSha) {
const items = [];
if (!fs.existsSync(EXTENSIONS_DIR)) {
return { items: [] };
return { items: [], filters: { keywords: [] } };
}
const extensionDirs = fs
@@ -761,32 +992,61 @@ function generateExtensionsData(gitDates, commitSha) {
"extension.mjs"
);
return fs.existsSync(extensionEntryPoint);
});
})
.sort((a, b) => a.name.localeCompare(b.name));
for (const dir of extensionDirs) {
const relPath = `extensions/${dir.name}`;
const assetInfo = getExtensionAssetInfo(
path.join(EXTENSIONS_DIR, dir.name),
relPath,
commitSha
);
const extensionDir = path.join(EXTENSIONS_DIR, dir.name);
const packageJsonPath = path.join(extensionDir, "package.json");
const packageJson = fs.existsSync(packageJsonPath)
? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
: {};
const keywords = Array.isArray(packageJson.keywords)
? [...new Set(packageJson.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
: [];
const extensionDescription = normalizeText(packageJson.description, "Canvas extension");
const extensionName = normalizeText(packageJson.name, dir.name);
const extensionVersion = normalizeText(packageJson.version, "1.0.0");
const screenshots = getExtensionAssetInfo(extensionDir, relPath, commitSha);
const canvasFiles = getExtensionCanvasFiles(extensionDir);
const canvases = [];
for (const canvasFile of canvasFiles) {
const source = fs.readFileSync(canvasFile, "utf-8");
canvases.push(...extractCanvasMetadataFromSource(source));
}
const canvasEntries = canvases.length > 0
? canvases
: [{ id: dir.name, displayName: formatDisplayName(dir.name), description: extensionDescription }];
const installUrl = `https://github.com/github/awesome-copilot/tree/${commitSha}/${relPath.replace(
/\\/g,
"/"
)}`;
extensions.push({
id: dir.name,
name: formatDisplayName(dir.name),
description: "Canvas extension",
path: relPath,
ref: commitSha,
lastUpdated: getDirectoryLastUpdated(gitDates, relPath),
imageUrl: assetInfo?.imageUrl || null,
assetPath: assetInfo?.assetPath || null,
installUrl: `https://github.com/github/awesome-copilot/tree/${commitSha}/${relPath.replace(
/\\/g,
"/"
)}`,
sourceUrl: null,
external: false,
});
for (const canvas of canvasEntries) {
const canvasId = normalizeText(canvas.id, dir.name);
const canvasName = normalizeText(canvas.displayName, formatDisplayName(canvasId));
const canvasDescription = normalizeText(extensionDescription, canvas.description);
items.push({
id: canvasId,
canvasId,
extensionId: dir.name,
extensionName,
name: canvasName,
version: extensionVersion,
description: canvasDescription,
path: relPath,
ref: commitSha,
lastUpdated: getDirectoryLastUpdated(gitDates, relPath),
screenshots: screenshots?.screenshots || { icon: null, gallery: null },
imageUrl: screenshots?.imageUrl || null,
assetPath: screenshots?.assetPath || null,
installUrl,
sourceUrl: null,
external: false,
keywords,
});
}
}
const externalJsonPath = path.join(EXTENSIONS_DIR, "external.json");
@@ -805,27 +1065,58 @@ function generateExtensionsData(gitDates, commitSha) {
}
const id = normalizeText(ext?.id || name.toLowerCase().replace(/\s+/g, "-"));
let imageUrl = normalizeText(ext?.imageUrl);
let assetPath = null;
const imagePath = normalizeText(ext?.imagePath);
if (!imageUrl && imagePath) {
const repoAssetPath = imagePath.replace(/\\/g, "/");
imageUrl = buildRepoImageUrl(repoAssetPath, commitSha);
assetPath = repoAssetPath;
}
const keywords = Array.isArray(ext?.keywords)
? [...new Set(ext.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
: Array.isArray(ext?.tags)
? [...new Set(ext.tags.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
: [];
const iconScreenshot =
normalizeExternalScreenshotRole(ext?.screenshots?.icon, commitSha) ||
normalizeExternalScreenshotRole(ext?.iconPath, commitSha) ||
normalizeExternalScreenshotRole(ext?.imagePath, commitSha) ||
normalizeExternalScreenshotRole(ext?.iconUrl, commitSha) ||
normalizeExternalScreenshotRole(ext?.imageUrl, commitSha);
const galleryScreenshot =
normalizeExternalScreenshotRole(ext?.screenshots?.gallery, commitSha) ||
normalizeExternalScreenshotRole(ext?.galleryPath, commitSha) ||
normalizeExternalScreenshotRole(ext?.galleryUrl, commitSha) ||
iconScreenshot;
const screenshots = {
icon: iconScreenshot
? {
path: iconScreenshot.path,
type: iconScreenshot.type,
}
: null,
gallery: galleryScreenshot
? {
path: galleryScreenshot.path,
type: galleryScreenshot.type,
}
: null,
};
const imageUrl = iconScreenshot?.imageUrl || null;
const assetPath = iconScreenshot?.path || null;
const canvasId = normalizeText(ext?.canvasId, id);
extensions.push({
items.push({
id,
canvasId,
extensionId: id,
extensionName: name,
name,
version: normalizeText(ext?.version, "1.0.0"),
description: normalizeText(ext?.description, "External canvas extension"),
path: null,
ref: null,
lastUpdated: null,
imageUrl: imageUrl || null,
screenshots,
imageUrl,
assetPath,
installUrl,
sourceUrl: sourceUrl || null,
external: true,
keywords,
});
}
}
@@ -834,11 +1125,98 @@ function generateExtensionsData(gitDates, commitSha) {
}
}
const sortedExtensions = extensions.sort((a, b) =>
a.name.localeCompare(b.name)
);
const sortedItems = items.sort((a, b) => a.name.localeCompare(b.name));
const keywordFilters = [...new Set(sortedItems.flatMap((item) => item.keywords || []))]
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
return { items: sortedExtensions };
return {
items: sortedItems,
filters: {
keywords: keywordFilters,
},
};
}
function generateExtensionsData(canvasManifestData) {
if (!canvasManifestData || !Array.isArray(canvasManifestData.items)) {
return { items: [], filters: { keywords: [] } };
}
const items = canvasManifestData.items.map((item) => ({
...item,
keywords: Array.isArray(item.keywords) ? item.keywords : [],
screenshots: item.screenshots || { icon: null, gallery: null },
}));
const filters = {
keywords: [...new Set(items.flatMap((item) => item.keywords))]
.filter(Boolean)
.sort((a, b) => a.localeCompare(b)),
};
return { items, filters };
}
function writePerExtensionCanvasManifests(canvasManifestData) {
const manifests = new Map();
function toExtensionRelativePath(assetPath, extensionId) {
const normalizedPath = normalizeText(assetPath).replace(/\\/g, "/");
if (!normalizedPath) return null;
const prefix = `extensions/${extensionId}/`;
return normalizedPath.startsWith(prefix)
? normalizedPath.slice(prefix.length)
: normalizedPath;
}
function toRelativeScreenshots(screenshots, extensionId) {
if (!screenshots) return { icon: null, gallery: null };
const toRelativeEntry = (entry) =>
entry
? {
...entry,
path: toExtensionRelativePath(entry.path, extensionId),
}
: null;
return {
icon: toRelativeEntry(screenshots.icon),
gallery: toRelativeEntry(screenshots.gallery),
};
}
for (const item of canvasManifestData.items || []) {
if (!item || item.external || !item.extensionId || !item.path) {
continue;
}
// We assume one canvas per extension folder.
if (manifests.has(item.extensionId)) {
continue;
}
manifests.set(item.extensionId, {
id: item.canvasId || item.id,
name: item.name,
description: item.description || "Canvas extension",
version: item.version || "1.0.0",
keywords: Array.isArray(item.keywords)
? [...new Set(item.keywords)].sort((a, b) => a.localeCompare(b))
: [],
screenshots: toRelativeScreenshots(
item.screenshots || { icon: null, gallery: null },
item.extensionId
),
});
}
for (const [extensionId, manifest] of manifests.entries()) {
const canvasManifestPath = path.join(
EXTENSIONS_DIR,
extensionId,
"canvas.json"
);
fs.writeFileSync(canvasManifestPath, JSON.stringify(manifest, null, 2));
}
}
/**
@@ -1181,9 +1559,12 @@ async function main() {
`✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)`
);
const extensionsData = generateExtensionsData(gitDates, commitSha);
const canvasManifestData = generateCanvasManifest(gitDates, commitSha);
const extensionsData = generateExtensionsData(canvasManifestData);
const extensions = extensionsData.items;
console.log(`✓ Generated ${extensions.length} extensions`);
console.log(
`✓ Generated ${extensions.length} extensions (${extensionsData.filters.keywords.length} keywords)`
);
const toolsData = generateToolsData();
const tools = toolsData.items;
@@ -1248,6 +1629,8 @@ async function main() {
JSON.stringify(extensionsData, null, 2)
);
writePerExtensionCanvasManifests(canvasManifestData);
fs.writeFileSync(
path.join(WEBSITE_DATA_DIR, "tools.json"),
JSON.stringify(toolsData, null, 2)
@@ -0,0 +1,24 @@
{
"id": "accessibility-kanban",
"name": "Accessibility Kanban",
"description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.",
"version": "1.0.0",
"keywords": [
"accessibility",
"github-issues",
"issue-triage",
"kanban-board",
"planning-workflow",
"status-tracking"
],
"screenshots": {
"icon": {
"path": "assets/preview.png",
"type": "image/png"
},
"gallery": {
"path": "assets/preview.png",
"type": "image/png"
}
}
}
+10 -1
View File
@@ -5,5 +5,14 @@
"main": "extension.mjs",
"dependencies": {
"@github/copilot-sdk": "latest"
}
},
"description": "Users drag accessibility issues across kanban lanes to plan, track, and complete remediation work.",
"keywords": [
"accessibility",
"kanban-board",
"issue-triage",
"planning-workflow",
"status-tracking",
"github-issues"
]
}
@@ -0,0 +1,24 @@
{
"id": "backlog-swipe-triage",
"name": "Backlog Swipe Triage",
"description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.",
"version": "1.0.0",
"keywords": [
"agent-assignment",
"backlog-triage",
"github-issues",
"issue-prioritization",
"swipe-interface",
"workflow-automation"
],
"screenshots": {
"icon": {
"path": "assets/preview.png",
"type": "image/png"
},
"gallery": {
"path": "assets/preview.png",
"type": "image/png"
}
}
}
+10 -2
View File
@@ -3,8 +3,16 @@
"version": "1.0.0",
"type": "module",
"main": "extension.mjs",
"description": "Swipe-driven backlog triage canvas for reviewing open issues and assigning implementation work.",
"dependencies": {
"@github/copilot-sdk": "1.0.1"
}
},
"description": "Users quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.",
"keywords": [
"backlog-triage",
"swipe-interface",
"issue-prioritization",
"github-issues",
"agent-assignment",
"workflow-automation"
]
}
@@ -0,0 +1,25 @@
{
"id": "chromium-control-canvas",
"name": "Chromium Control Canvas",
"description": "Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.",
"version": "1.0.0",
"keywords": [
"browser-control",
"chromium-browser",
"interactive-canvas",
"playwright-automation",
"screenshots",
"ui-testing",
"web-navigation"
],
"screenshots": {
"icon": {
"path": "assets/preview.png",
"type": "image/png"
},
"gallery": {
"path": "assets/preview.png",
"type": "image/png"
}
}
}
@@ -561,7 +561,7 @@ const session = await joinSession({
id: "chromium-control-canvas",
displayName: "Chromium Control Canvas",
description:
"Control canvas for a real headful Chromium window driven by Playwright.",
"Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.",
inputSchema: {
type: "object",
properties: {
@@ -1,14 +1,22 @@
{
"name": "chromium-control-canvas",
"version": "1.0.0",
"description": "GitHub Copilot canvas that drives a real headful Chromium window via Playwright.",
"main": "extension.mjs",
"keywords": [],
"author": "Andrea Griffiths",
"license": "MIT",
"type": "module",
"dependencies": {
"@github/copilot-sdk": "latest",
"playwright": "^1.60.0"
}
},
"description": "Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.",
"keywords": [
"chromium-browser",
"playwright-automation",
"browser-control",
"interactive-canvas",
"web-navigation",
"screenshots",
"ui-testing"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"id": "color-orb",
"name": "Color Orb",
"description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.",
"version": "1.0.0",
"keywords": [
"agent-actions",
"color-picker",
"interactive-demo",
"realtime-updates",
"sse-events",
"visual-feedback"
],
"screenshots": {
"icon": {
"path": "assets/preview.png",
"type": "image/png"
},
"gallery": {
"path": "assets/preview.png",
"type": "image/png"
}
}
}
+10 -1
View File
@@ -5,5 +5,14 @@
"main": "extension.mjs",
"dependencies": {
"@github/copilot-sdk": "latest"
}
},
"description": "Gives users a visual orb they can ask the agent to recolor while showing a live activity log in the canvas.",
"keywords": [
"color-picker",
"interactive-demo",
"agent-actions",
"realtime-updates",
"sse-events",
"visual-feedback"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"id": "diagram",
"name": "Diagram Explorer",
"description": "Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.",
"version": "1.0.0",
"keywords": [
"architecture-mapping",
"canvas-navigation",
"exploratory-analysis",
"interactive-diagrams",
"node-drilldown",
"relationship-visualization"
],
"screenshots": {
"icon": {
"path": "assets/preview.png",
"type": "image/png"
},
"gallery": {
"path": "assets/preview.png",
"type": "image/png"
}
}
}
+10 -1
View File
@@ -5,5 +5,14 @@
"main": "extension.mjs",
"dependencies": {
"@github/copilot-sdk": "latest"
}
},
"description": "Lets users render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.",
"keywords": [
"interactive-diagrams",
"architecture-mapping",
"node-drilldown",
"relationship-visualization",
"exploratory-analysis",
"canvas-navigation"
]
}
+15
View File
@@ -3,6 +3,21 @@
"id": "coffilot",
"name": "Coffilot",
"description": "Java-focused Copilot canvas extension from jdubois.",
"keywords": [
"java",
"canvas",
"productivity"
],
"screenshots": {
"icon": {
"path": "extensions/external-assets/coffilot-preview.png",
"type": "image/png"
},
"gallery": {
"path": "extensions/external-assets/coffilot-preview.png",
"type": "image/png"
}
},
"installUrl": "https://github.com/jdubois/coffilot",
"sourceUrl": "https://github.com/jdubois/coffilot",
"imagePath": "extensions/external-assets/coffilot-preview.png"
+24
View File
@@ -0,0 +1,24 @@
{
"id": "feedback-themes",
"name": "Feedback Themes",
"description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.",
"version": "1.0.0",
"keywords": [
"customer-feedback",
"impact-prioritization",
"product-insights",
"signal-grouping",
"theme-analysis",
"trend-discovery"
],
"screenshots": {
"icon": {
"path": "assets/preview.png",
"type": "image/png"
},
"gallery": {
"path": "assets/preview.png",
"type": "image/png"
}
}
}
+10 -1
View File
@@ -5,5 +5,14 @@
"main": "extension.mjs",
"dependencies": {
"@github/copilot-sdk": "latest"
}
},
"description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.",
"keywords": [
"customer-feedback",
"theme-analysis",
"signal-grouping",
"impact-prioritization",
"product-insights",
"trend-discovery"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"id": "gesture-review",
"name": "Gesture PR Review",
"description": "Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.",
"version": "1.0.0",
"keywords": [
"camera-input",
"gesture-control",
"github-prs",
"hands-free",
"mediapipe",
"pull-request-review"
],
"screenshots": {
"icon": {
"path": "assets/preview.png",
"type": "image/png"
},
"gallery": {
"path": "assets/preview.png",
"type": "image/png"
}
}
}
+1 -1
View File
@@ -179,7 +179,7 @@ const canvas = createCanvas({
id: "gesture-review",
displayName: "Gesture PR Review",
description:
"Interactive PR review using hand gestures. Shows a live camera feed and detects thumbs up (approve) or thumbs down (reject) via MediaPipe hand tracking.",
"Users review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.",
actions: [
{
name: "show_pr",
+10 -1
View File
@@ -5,5 +5,14 @@
"main": "extension.mjs",
"dependencies": {
"@github/copilot-sdk": "latest"
}
},
"description": "Users review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.",
"keywords": [
"pull-request-review",
"gesture-control",
"camera-input",
"hands-free",
"github-prs",
"mediapipe"
]
}
@@ -0,0 +1,24 @@
{
"id": "release-notes-showcase",
"name": "Release Notes Showcase",
"description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.",
"version": "1.0.0",
"keywords": [
"changelog",
"contributor-callouts",
"email-export",
"launch-summary",
"product-updates",
"release-notes"
],
"screenshots": {
"icon": {
"path": "assets/preview.png",
"type": "image/png"
},
"gallery": {
"path": "assets/preview.png",
"type": "image/png"
}
}
}
+10 -2
View File
@@ -3,8 +3,16 @@
"version": "1.0.0",
"type": "module",
"main": "extension.mjs",
"description": "Release notes canvas for building polished release communications and exports.",
"dependencies": {
"@github/copilot-sdk": "1.0.1"
}
},
"description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.",
"keywords": [
"release-notes",
"launch-summary",
"changelog",
"contributor-callouts",
"product-updates",
"email-export"
]
}
@@ -621,7 +621,7 @@ export const releaseNotesShowcaseCanvas = createCanvas({
id: CANVAS_ID,
displayName: CANVAS_TITLE,
description:
"Presents release notes as a high-impact launch summary with contributor callouts and email-ready export output.",
"Compose and refine launch-ready release notes with contributor callouts and export-friendly output.",
inputSchema: releaseNotesInputSchema,
actions: [
{
+24
View File
@@ -0,0 +1,24 @@
{
"id": "where-was-i",
"name": "Where Was I?",
"description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
"version": "1.0.0",
"keywords": [
"branch-state",
"developer-context",
"git-history",
"interrupt-recovery",
"pull-request-context",
"resume-work"
],
"screenshots": {
"icon": {
"path": "assets/preview.png",
"type": "image/png"
},
"gallery": {
"path": "assets/preview.png",
"type": "image/png"
}
}
}
+1 -1
View File
@@ -666,7 +666,7 @@ const session = await joinSession({
createCanvas({
id: "where-was-i",
displayName: "Where Was I?",
description: "Interrupt Recovery — reconstructs your working context (branch, commits, changes, PRs) so you can resume after being pulled away.",
description: "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
actions: [
{
name: "refresh",
+18
View File
@@ -0,0 +1,18 @@
{
"name": "where-was-i",
"version": "1.0.0",
"type": "module",
"main": "extension.mjs",
"description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
"keywords": [
"interrupt-recovery",
"developer-context",
"git-history",
"branch-state",
"resume-work",
"pull-request-context"
],
"dependencies": {
"@github/copilot-sdk": "latest"
}
}
+6 -1
View File
@@ -20,9 +20,13 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
<div class="listing-toolbar-row">
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} extensions</div>
<details class="listing-controls">
<summary class="listing-controls-trigger btn btn-secondary btn-small">Sort</summary>
<summary class="listing-controls-trigger btn btn-secondary btn-small">Sort &amp; Filter</summary>
<div class="listing-controls-panel">
<div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-keyword">Keyword:</label>
<select id="filter-keyword" multiple aria-label="Filter by keyword"></select>
</div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort extensions">
@@ -30,6 +34,7 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div>
</div>
</details>
@@ -2,11 +2,26 @@ import { escapeHtml, getGitHubUrl, getLastUpdatedHtml } from "../utils";
export interface RenderableExtension {
id: string;
canvasId?: string;
extensionId?: string;
extensionName?: string;
name: string;
path?: string | null;
ref?: string | null;
version?: string | null;
description?: string;
lastUpdated?: string | null;
keywords?: string[];
screenshots?: {
icon?: {
path?: string | null;
type?: string | null;
} | null;
gallery?: {
path?: string | null;
type?: string | null;
} | null;
} | null;
imageUrl?: string | null;
assetPath?: string | null;
installUrl?: string | null;
@@ -69,6 +84,18 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
<div class="resource-description">${escapeHtml(
item.description || "Canvas extension"
)}</div>
<div class="resource-keywords">
${
item.keywords && item.keywords.length > 0
? item.keywords
.map(
(kw) =>
`<span class="keyword-tag">${escapeHtml(kw)}</span>`
)
.join("")
: ""
}
</div>
<div class="resource-meta">
${
item.external
+84 -2
View File
@@ -1,10 +1,17 @@
/**
* Canvas extensions page functionality
*/
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from "../choices";
import {
copyToClipboard,
fetchData,
getQueryParam,
getQueryParamValues,
showToast,
updateQueryParams,
} from "../utils";
@@ -17,14 +24,22 @@ import {
interface Extension extends RenderableExtension {
lastUpdated?: string | null;
keywords?: string[];
}
interface ExtensionsData {
items: Extension[];
filters?: {
keywords?: string[];
};
}
let allItems: Extension[] = [];
let currentSort: ExtensionSortOption = "title";
let keywordSelect: Choices;
let currentFilters = {
keywords: [] as string[],
};
let actionHandlersReady = false;
function openPreviewModal(url: string, alt: string): void {
@@ -51,13 +66,33 @@ function closePreviewModal(): void {
document.body.style.overflow = "";
}
function sortItems(items: Extension[]): Extension[] {
return sortExtensions(items, currentSort);
}
function getCountText(resultsCount: number): string {
if (currentFilters.keywords.length === 0) {
return `${resultsCount} extension${resultsCount === 1 ? "" : "s"}`;
}
return `${resultsCount} of ${allItems.length} extensions (filtered by ${currentFilters.keywords.length} keyword${currentFilters.keywords.length === 1 ? "" : "s"})`;
}
function applySortAndRender(): void {
const countEl = document.getElementById("results-count");
const results = sortExtensions(allItems, currentSort);
let results = [...allItems];
if (currentFilters.keywords.length > 0) {
results = results.filter((item) =>
item.keywords?.some((keyword) => currentFilters.keywords.includes(keyword))
);
}
results = sortItems(results);
renderItems(results);
if (countEl) {
countEl.textContent = `${results.length} extension${results.length === 1 ? "" : "s"}`;
countEl.textContent = getCountText(results.length);
}
}
@@ -132,12 +167,15 @@ function setupActionHandlers(list: HTMLElement | null): void {
function syncUrlState(): void {
updateQueryParams({
q: "",
keyword: currentFilters.keywords,
sort: currentSort === "title" ? "" : currentSort,
});
}
export async function initExtensionsPage(): Promise<void> {
const list = document.getElementById("resource-list");
const clearFiltersBtn = document.getElementById("clear-filters");
const sortSelect = document.getElementById(
"sort-select"
) as HTMLSelectElement;
@@ -154,19 +192,63 @@ export async function initExtensionsPage(): Promise<void> {
allItems = data.items;
const availableKeywords = (
data.filters?.keywords ||
Array.from(
new Set(
data.items.flatMap((item) =>
Array.isArray(item.keywords) ? item.keywords : []
)
)
)
).sort((a, b) => a.localeCompare(b));
keywordSelect = createChoices("#filter-keyword", {
placeholderValue: "All Keywords",
});
keywordSelect.setChoices(
availableKeywords.map((keyword) => ({ value: keyword, label: keyword })),
"value",
"label",
true
);
const initialKeywords = getQueryParamValues("keyword").filter((keyword) =>
availableKeywords.includes(keyword)
);
const initialSort = getQueryParam("sort");
if (initialKeywords.length > 0) {
currentFilters.keywords = initialKeywords;
setChoicesValues(keywordSelect, initialKeywords);
}
if (initialSort === "lastUpdated") {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
document.getElementById("filter-keyword")?.addEventListener("change", () => {
currentFilters.keywords = getChoicesValues(keywordSelect);
applySortAndRender();
syncUrlState();
});
sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as ExtensionSortOption;
applySortAndRender();
syncUrlState();
});
clearFiltersBtn?.addEventListener("click", () => {
currentFilters = { keywords: [] };
currentSort = "title";
keywordSelect.removeActiveItems();
if (sortSelect) sortSelect.value = "title";
applySortAndRender();
syncUrlState();
});
applySortAndRender();
syncUrlState();
}
// Auto-initialize when DOM is ready
+17
View File
@@ -1971,6 +1971,23 @@ body:has(#main-content) {
color: var(--color-text-muted);
}
.resource-keywords {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.keyword-tag {
display: inline-block;
font-size: 11px;
padding: 3px 8px;
background-color: var(--color-bg-tertiary);
border-radius: 10px;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.resource-actions {
display: flex;
gap: 8px;