diff --git a/.github/workflows/build-vsix.yml b/.github/workflows/build-vsix.yml new file mode 100644 index 0000000..b4bdd12 --- /dev/null +++ b/.github/workflows/build-vsix.yml @@ -0,0 +1,95 @@ +name: Build VSIX + +on: + pull_request: + branches: + - main + types: [opened, synchronize, reopened] + +jobs: + build-vsix: + name: Build installable VSIX + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run linting + run: npm run lint + + - name: Sanitize branch name + id: branch + run: echo "name=$(echo '${{ github.head_ref }}' | tr '/' '-')" >> "$GITHUB_OUTPUT" + + - name: Stamp pre-release version + run: | + BASE=$(node -p "require('./package.json').version") + jq ".version = \"${BASE}-pr.${{ github.event.pull_request.number }}\"" package.json > package.tmp.json + mv package.tmp.json package.json + + - name: Package extension + run: npx @vscode/vsce package --no-dependencies -o cloudinary-${{ steps.branch.outputs.name }}.vsix + + - name: Upload VSIX artifact + id: upload + uses: actions/upload-artifact@v4 + with: + name: cloudinary-${{ steps.branch.outputs.name }} + path: cloudinary-${{ steps.branch.outputs.name }}.vsix + retention-days: 14 + + - name: Post download link to PR + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const version = require('./package.json').version; + const body = [ + marker, + `### πŸ“¦ Test VSIX ready`, + `**Version:** \`${version}\``, + `**Artifact:** [cloudinary-${{ steps.branch.outputs.name }}](${runUrl})`, + ``, + `**To install:**`, + `1. Download the \`.vsix\` from the link above (under Artifacts)`, + `2. In VS Code: \`Extensions β†’ Β·Β·Β· β†’ Install from VSIX…\``, + ` or run: \`code --install-extension cloudinary-${{ steps.branch.outputs.name }}.vsix\``, + ``, + `_Updated on every push. Artifact expires after 14 days._`, + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/.gitignore b/.gitignore index e29aa63..64ed14c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ node_modules/ # Internal development files internal/ +docs/superpowers/ # Logs *.log diff --git a/esbuild.js b/esbuild.js index c207238..c32bb96 100644 --- a/esbuild.js +++ b/esbuild.js @@ -25,6 +25,7 @@ async function main() { "src/webview/client/preview.ts", "src/webview/client/upload-widget.ts", "src/webview/client/welcome.ts", + "src/webview/client/homescreen.ts", ], bundle: true, format: "iife", diff --git a/media/styles/homescreen.css b/media/styles/homescreen.css new file mode 100644 index 0000000..992078c --- /dev/null +++ b/media/styles/homescreen.css @@ -0,0 +1,603 @@ +/** + * Homescreen sidebar panel styles. + */ + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { background: var(--vscode-sideBar-background); } + +.hs-root { + display: flex; + flex-direction: column; + min-height: 100vh; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +/* ── Header ── */ +.hs-header { + padding: 18px 16px 16px; + background: linear-gradient(145deg, #1e3a8a 0%, #3448C5 55%, #5b73f0 100%); + position: relative; + overflow: hidden; + flex-shrink: 0; + animation: hs-in 0.18s ease both; +} +.hs-header::before { + content: ''; + position: absolute; + top: -24px; right: -24px; + width: 110px; height: 110px; + background: rgba(255,255,255,0.06); + border-radius: 50%; + pointer-events: none; +} +.hs-header::after { + content: ''; + position: absolute; + bottom: -32px; left: 30px; + width: 70px; height: 70px; + background: rgba(255,255,255,0.04); + border-radius: 50%; + pointer-events: none; +} + +.hs-brand { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; + position: relative; +} +.hs-brand-left { + display: flex; + align-items: center; + gap: 8px; +} +.hs-brand-icon { width: 22px; height: 22px; flex-shrink: 0; } +.hs-brand-name { + font-size: 11px; + font-weight: 700; + letter-spacing: 1.2px; + text-transform: uppercase; + color: rgba(255,255,255,0.95); +} + +.hs-cloud-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + position: relative; +} +.hs-cloud-col { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.hs-cloud-name { + font-size: 15px; + font-weight: 600; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 160px; +} +.hs-cloud-name--placeholder { + font-size: 13px; + font-weight: 400; + color: rgba(255,255,255,0.6); + font-style: italic; +} +.hs-folder-mode { + font-size: 10px; + color: rgba(255,255,255,0.5); + font-weight: 400; + letter-spacing: 0.2px; + white-space: nowrap; +} +.hs-configure-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 4px; + cursor: pointer; + color: rgba(255,255,255,0.7); + flex-shrink: 0; + transition: background 0.12s, color 0.12s; + padding: 0; +} +.hs-configure-btn:hover { + background: rgba(255,255,255,0.2); + color: #fff; +} +.hs-configure-btn:focus-visible { + outline: 1px solid rgba(255,255,255,0.5); + outline-offset: 1px; +} +.hs-status-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + border-radius: 20px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.2px; + color: rgba(255,255,255,0.92); + background: rgba(255,255,255,0.14); + flex-shrink: 0; +} +.hs-status-dot { + width: 6px; height: 6px; + border-radius: 50%; + background: #4ade80; + box-shadow: 0 0 5px rgba(74,222,128,0.8); + flex-shrink: 0; +} +.hs-status-dot--warn { + background: #fbbf24; + box-shadow: 0 0 5px rgba(251,191,36,0.8); +} + +/* ── Setup banner ── */ +.hs-setup-banner { + margin: 10px 10px 0; + padding: 9px 11px; + border-radius: 8px; + background: rgba(251,191,36,0.08); + border: 1px solid rgba(251,191,36,0.22); + display: flex; + align-items: center; + gap: 8px; + animation: hs-in 0.2s ease both; +} +.hs-setup-banner-icon { + font-size: 13px; + flex-shrink: 0; + line-height: 1; +} +.hs-setup-banner-text { + flex: 1; + font-size: 11px; + color: var(--vscode-foreground); + opacity: 0.85; + line-height: 1.4; +} +.hs-setup-banner-btn { + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + color: #f59e0b; + background: rgba(251,191,36,0.14); + border: 1px solid rgba(251,191,36,0.3); + border-radius: 5px; + padding: 3px 9px; + cursor: pointer; + font-family: var(--vscode-font-family); + transition: background 0.12s; +} +.hs-setup-banner-btn:hover { background: rgba(251,191,36,0.24); } + +/* ── Search ── */ +.hs-search { + padding: 6px 8px 4px; + animation: hs-in 0.18s ease 0.02s both; +} +.hs-search-wrap { + display: flex; + align-items: center; + gap: 6px; + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, rgba(128,128,128,0.28)); + border-radius: 6px; + padding: 0 8px; + transition: border-color 0.15s; +} +.hs-search-wrap:focus-within { + border-color: var(--vscode-focusBorder); +} +.hs-search-icon { + color: var(--vscode-input-placeholderForeground); + flex-shrink: 0; + pointer-events: none; +} +.hs-search-input { + flex: 1; + background: none; + border: none; + outline: none; + font-family: var(--vscode-font-family); + font-size: 12px; + color: var(--vscode-input-foreground); + padding: 5px 0; + min-width: 0; +} +.hs-search-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} +.hs-search-clear { + background: none; + border: none; + cursor: pointer; + color: var(--vscode-input-placeholderForeground); + font-size: 15px; + padding: 0; + line-height: 1; + display: flex; + align-items: center; + border-radius: 3px; + transition: color 0.1s; +} +.hs-search-clear:hover { color: var(--vscode-foreground); } +.hs-search-clear:focus-visible { + outline: 1px solid var(--vscode-focusBorder); +} + +/* ── Actions ── */ +.hs-actions { + padding: 8px; + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; + animation: hs-in 0.22s ease 0.04s both; +} + +.hs-action { + display: flex; + align-items: center; + gap: 11px; + width: 100%; + padding: 9px 10px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + font-family: var(--vscode-font-family); + text-align: left; + transition: background 0.12s ease; +} +.hs-action:hover { background: var(--vscode-list-hoverBackground); } +.hs-action:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + border-radius: 7px; +} +.hs-action:disabled { + cursor: default; + opacity: 0.55; +} +.hs-action:disabled:hover { background: transparent; } + +.hs-action-icon { + width: 30px; height: 30px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.hs-action-icon--blue { background: rgba(52,72,197,0.14); color: #3448C5; } +.hs-action-icon--green { background: rgba(16,185,129,0.12); color: #10b981; } +.hs-action-icon--violet{ background: rgba(139,92,246,0.12); color: #8b5cf6; } +.hs-action-icon--amber { background: rgba(245,158,11,0.12); color: #f59e0b; } + +.hs-action-text { flex: 1; min-width: 0; } +.hs-action-title { + font-size: 12.5px; + font-weight: 500; + color: var(--vscode-foreground); + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.hs-action-desc { + font-size: 10.5px; + color: var(--vscode-descriptionForeground); + line-height: 1.3; + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.hs-chip { + flex-shrink: 0; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 4px; + background: rgba(139,92,246,0.14); + color: #8b5cf6; + border: 1px solid rgba(139,92,246,0.2); +} + +.hs-chevron { + flex-shrink: 0; + color: var(--vscode-descriptionForeground); + opacity: 0.4; + transition: transform 0.2s; +} +.hs-chevron--open { transform: rotate(90deg); } + +.hs-section-divider { + height: 1px; + margin: 4px 6px; + background: var(--vscode-panel-border, rgba(128,128,128,0.14)); +} + +/* ── Footer ── */ +.hs-footer { + padding: 8px 16px 12px; + border-top: 1px solid var(--vscode-panel-border, rgba(128,128,128,0.12)); + animation: hs-in 0.22s ease 0.08s both; +} +.hs-footer-link { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--vscode-textLink-foreground); + cursor: pointer; + text-decoration: none; + background: none; + border: none; + font-family: var(--vscode-font-family); + padding: 0; +} +.hs-footer-link:hover { text-decoration: underline; } +.hs-footer-link:focus-visible { outline: 1px solid var(--vscode-focusBorder); } + +/* ── Animations ── */ +@keyframes hs-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── AI Tools accordion ── */ +#hs-btn-ai-tools { user-select: none; } +#hs-btn-ai-tools.expanded { + background: var(--vscode-list-hoverBackground); + border-radius: 7px 7px 0 0; +} + +.hs-ai-panel { + overflow: hidden; + max-height: 0; + transition: max-height 0.28s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 0 0 7px 7px; + background: rgba(255,255,255,0.02); + border-top: 1px solid transparent; +} +.hs-ai-panel.open { + max-height: 520px; + border-top-color: var(--vscode-panel-border, rgba(128,128,128,0.14)); +} +.hs-ai-panel-inner { + padding: 10px 10px 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Loading skeletons */ +.hs-ai-loading { display: flex; flex-direction: column; gap: 6px; } +.hs-skeleton { + height: 22px; + border-radius: 4px; + background: linear-gradient( + 90deg, + rgba(255,255,255,0.04) 0%, + rgba(255,255,255,0.09) 50%, + rgba(255,255,255,0.04) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.4s ease infinite; +} +.hs-skeleton--short { width: 55%; } +.hs-skeleton--label { height: 10px; width: 38%; margin-bottom: 4px; } +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Section headers */ +.hs-ai-section-head { + display: flex; + align-items: center; + gap: 7px; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.9px; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + margin-bottom: 5px; +} +.hs-ai-section-head::after { + content: ''; + flex: 1; + height: 1px; + background: var(--vscode-panel-border, rgba(128,128,128,0.14)); +} + +/* Checklist items */ +.hs-ai-item { + display: flex; + align-items: center; + gap: 7px; + padding: 3px 4px 3px 2px; + border-radius: 4px; + transition: background 0.1s; + cursor: pointer; + animation: hs-row-in 0.18s ease both; +} +.hs-ai-item:hover { background: var(--vscode-list-hoverBackground); } +.hs-ai-item:nth-child(1) { animation-delay: .05s; } +.hs-ai-item:nth-child(2) { animation-delay: .09s; } +.hs-ai-item:nth-child(3) { animation-delay: .13s; } +.hs-ai-item:nth-child(4) { animation-delay: .17s; } +.hs-ai-item:nth-child(5) { animation-delay: .21s; } +@keyframes hs-row-in { + from { opacity: 0; transform: translateX(-4px); } + to { opacity: 1; transform: translateX(0); } +} + +/* Custom checkbox */ +.hs-ai-cb { + appearance: none; + -webkit-appearance: none; + width: 12px; + height: 12px; + flex-shrink: 0; + border: 1.5px solid var(--vscode-checkbox-border); + border-radius: 2px; + background: var(--vscode-checkbox-background); + cursor: pointer; + position: relative; + transition: border-color 0.1s, background 0.1s; +} +.hs-ai-cb:checked { + background: var(--vscode-button-background); + border-color: var(--vscode-button-background); +} +.hs-ai-cb:checked::after { + content: ''; + position: absolute; + left: 2px; top: -1px; + width: 5px; height: 8px; + border: 1.5px solid var(--vscode-button-foreground); + border-top: none; + border-left: none; + transform: rotate(45deg); +} +.hs-ai-cb:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.hs-ai-item-name { + flex: 1; + font-size: 11px; + color: var(--vscode-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +/* Status indicator */ +.hs-ai-item-status { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; + font-size: 9.5px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} +.hs-ai-item-status::before { + content: ''; + display: inline-block; + width: 5px; + height: 5px; + border-radius: 1px; + flex-shrink: 0; +} +.hs-ai-item-status--ok::before { background: #4ade80; } +.hs-ai-item-status--none::before { background: rgba(255,255,255,0.15); } +.hs-ai-platform-badge { + font-size: 9px; + font-weight: 400; + color: var(--vscode-descriptionForeground); + margin-left: 4px; +} +.hs-ai-platform-sub { + display: block; + font-size: 9px; + font-weight: 400; + color: var(--vscode-descriptionForeground); + margin-top: 1px; +} + +.hs-ai-item input[type="checkbox"]:disabled { opacity: 0.5; cursor: default; } +.hs-ai-hint { + font-size: 9px; + color: var(--vscode-descriptionForeground); + margin: 3px 0 0 0; + padding: 0; +} + +/* Progress tick */ +.hs-ai-item-tick { + flex-shrink: 0; + width: 13px; + height: 13px; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + animation: tick-in 0.2s cubic-bezier(0.34,1.56,0.64,1) both; +} +@keyframes tick-in { + from { opacity: 0; transform: scale(0); } + to { opacity: 1; transform: scale(1); } +} +.hs-ai-item-tick--ok { color: #4ade80; } +.hs-ai-item-tick--err { color: var(--vscode-errorForeground); } + +/* Apply button */ +.hs-ai-apply { + width: 100%; + padding: 6px 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); + border: none; + border-radius: 5px; + cursor: pointer; + font-family: var(--vscode-font-family); + transition: opacity 0.12s; + position: relative; + overflow: hidden; + margin-top: 2px; +} +.hs-ai-apply::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(255,255,255,0); + transition: background 0.12s; +} +.hs-ai-apply:hover::after { background: rgba(255,255,255,0.08); } +.hs-ai-apply:disabled { opacity: 0.35; cursor: default; } +.hs-ai-apply:disabled::after { background: none; } +.hs-ai-apply:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +/* Error banner */ +.hs-ai-error { + font-size: 10.5px; + color: var(--vscode-errorForeground); + padding: 5px 7px; + border-radius: 4px; + background: rgba(241,76,76,0.08); + border: 1px solid rgba(241,76,76,0.2); +} + +.hidden { display: none !important; } diff --git a/package.json b/package.json index 55de9be..8654c94 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,16 @@ }, "views": { "cloudinary": [ + { + "id": "cloudinaryHomescreen", + "name": "Cloudinary", + "type": "webview", + "when": "cloudinary.activeView != 'library'" + }, { "id": "cloudinaryMediaLibrary", - "name": "" + "name": "", + "when": "cloudinary.activeView == 'library'" } ] }, @@ -112,10 +119,31 @@ "command": "cloudinary.openWelcomeScreen", "title": "Open Welcome Guide", "category": "Cloudinary" + }, + { + "command": "cloudinary.showHomescreen", + "title": "Go to Home", + "icon": "$(home)", + "category": "Cloudinary" + }, + { + "command": "cloudinary.showLibrary", + "title": "Browse Media Library", + "category": "Cloudinary" + }, + { + "command": "cloudinary.configureAiTools", + "title": "Configure AI Tools", + "category": "Cloudinary" } ], "menus": { "view/title": [ + { + "command": "cloudinary.showHomescreen", + "when": "view == cloudinaryMediaLibrary", + "group": "navigation@0" + }, { "command": "cloudinary.refresh", "when": "view == cloudinaryMediaLibrary", diff --git a/src/aiToolsService.ts b/src/aiToolsService.ts new file mode 100644 index 0000000..34fea86 --- /dev/null +++ b/src/aiToolsService.ts @@ -0,0 +1,559 @@ +import * as vscode from "vscode"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type EditorType = "cursor" | "vscode" | "windsurf" | "antigravity" | "unknown"; + +export type McpServerDef = { + label: string; + description: string; + key: string; + config: Record; +}; + +export type SkillInfo = { + name: string; + description: string; + dirName: string; +}; + +export type PlatformId = "universal" | "claude-code" | "vscode-copilot" | "windsurf"; + +export type PlatformDef = { + id: PlatformId; + label: string; + sublabel?: string; +}; + +export const PLATFORMS: PlatformDef[] = [ + { id: "universal", label: "Universal", sublabel: "Cursor, Codex, Amp, Warp + more" }, + { id: "claude-code", label: "Claude Code" }, + { id: "vscode-copilot", label: "VS Code (Copilot)" }, + { id: "windsurf", label: "Windsurf" }, +]; + +type GitHubEntry = { + name: string; + type: "file" | "dir"; +}; + +type GitHubFile = { + content: string; // base64-encoded + encoding: string; +}; + +// ── Editor detection ────────────────────────────────────────────────────────── + +export function detectEditor(): EditorType { + const uriScheme = vscode.env.uriScheme.toLowerCase(); + if (uriScheme === "cursor") { return "cursor"; } + if (uriScheme === "windsurf") { return "windsurf"; } + if (uriScheme === "antigravity" || uriScheme === "gemini") { return "antigravity"; } + if (uriScheme === "vscode" || uriScheme === "vscode-insiders") { return "vscode"; } + const appName = vscode.env.appName.toLowerCase(); + if (appName.includes("cursor")) { return "cursor"; } + if (appName.includes("windsurf")) { return "windsurf"; } + if (appName.includes("antigravity") || appName.includes("gemini")) { return "antigravity"; } + if (appName.includes("visual studio code") || appName.includes("vscode")) { return "vscode"; } + return "unknown"; +} + +export function getMcpFilePath(editor: EditorType): string { + switch (editor) { + case "cursor": return ".cursor/mcp.json"; + case "windsurf": return ".windsurf/mcp.json"; + case "antigravity": return ".agent/mcp_config.json"; + case "vscode": + default: return ".vscode/mcp.json"; + } +} + +export function detectEditorPlatform(): PlatformId { + const editor = detectEditor(); + if (editor === "windsurf") { return "windsurf"; } + if (editor === "vscode") { return "vscode-copilot"; } + if (editor === "cursor" || editor === "antigravity") { return "universal"; } + return "claude-code"; // claude-code, unknown +} + +// ── GitHub API helpers ──────────────────────────────────────────────────────── + +const SKILLS_BASE = "https://api.github.com/repos/cloudinary-devs/skills/contents"; + +export async function githubFetchJson(url: string): Promise { + const baseHeaders: Record = { Accept: "application/vnd.github+json" }; + + let response = await fetch(url, { headers: baseHeaders }); + + if (!response.ok && [401, 403, 404].includes(response.status)) { + try { + const session = await vscode.authentication.getSession("github", ["repo"], { createIfNone: true }); + if (session) { + response = await fetch(url, { + headers: { ...baseHeaders, Authorization: `Bearer ${session.accessToken}` }, + }); + } + } catch { + // auth declined or unavailable β€” fall through with original error + } + } + + if (!response.ok) { + throw new Error(`GitHub API ${response.status}: ${url}`); + } + return response.json() as Promise; +} + +export function decodeBase64(encoded: string): string { + return Buffer.from(encoded.replace(/\n/g, ""), "base64").toString("utf-8"); +} + +// ── Frontmatter helpers ─────────────────────────────────────────────────────── + +export function parseFrontmatter(content: string): Record { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) { return {}; } + const result: Record = {}; + for (const line of match[1].split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) { continue; } + const key = line.slice(0, colonIdx).trim(); + const value = line.slice(colonIdx + 1).trim(); + if (key) { result[key] = value; } + } + return result; +} + +export function getBodyAfterFrontmatter(content: string): string { + return content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim(); +} + +export function toMdcContent(content: string): string { + return content.replace(/^(---\n)([\s\S]*?)(\n---)/, (_, open, body, close) => { + const filtered = body + .split("\n") + .filter((line: string) => !line.startsWith("name:")) + .join("\n"); + return `${open}${filtered}${close}`; + }); +} + +// ── Skill fetching ──────────────────────────────────────────────────────────── + +export async function fetchSkillList(): Promise { + const entries = await githubFetchJson(`${SKILLS_BASE}/skills`); + const dirs = entries.filter((e) => e.type === "dir"); + + const results = await Promise.all( + dirs.map(async (dir): Promise => { + try { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${dir.name}/SKILL.md` + ); + const content = decodeBase64(file.content); + const fm = parseFrontmatter(content); + return { name: fm.name || dir.name, description: fm.description || "", dirName: dir.name }; + } catch { + return null; + } + }) + ); + + return results.filter((s): s is SkillInfo => s !== null); +} + +export async function fetchSkillContent(skillName: string): Promise { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/SKILL.md` + ); + return decodeBase64(file.content); +} + +export async function fetchReferenceFiles( + skillName: string +): Promise> { + let entries: GitHubEntry[]; + try { + entries = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references` + ); + } catch { + return []; + } + + const results = await Promise.all( + entries + .filter((e) => e.type === "file") + .map(async (e) => { + try { + const file = await githubFetchJson( + `${SKILLS_BASE}/skills/${skillName}/references/${e.name}` + ); + return { name: e.name, content: decodeBase64(file.content) }; + } catch { + return null; + } + }) + ); + return results.filter((f): f is { name: string; content: string } => f !== null); +} + +// ── Filesystem helpers ──────────────────────────────────────────────────────── + +export async function ensureDir(uri: vscode.Uri): Promise { + try { await vscode.workspace.fs.createDirectory(uri); } catch { /* already exists */ } +} + +export async function writeWithOverwriteCheck( + uri: vscode.Uri, + content: string, + label: string +): Promise { + try { + await vscode.workspace.fs.stat(uri); + const answer = await vscode.window.showWarningMessage( + `${label} already exists. Overwrite?`, + "Yes", + "No" + ); + if (answer !== "Yes") { return false; } + } catch { + // file doesn't exist β€” proceed + } + await ensureDir(vscode.Uri.joinPath(uri, "..")); + await vscode.workspace.fs.writeFile(uri, Buffer.from(content, "utf-8")); + return true; +} + +// ── Skill installation β€” per IDE ────────────────────────────────────────────── + +export async function installForClaudeCode( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[], + errors: string[] +): Promise { + const skillFile = vscode.Uri.joinPath( + rootUri, `.claude/skills/${skillName}/SKILL.md` + ); + const written = await writeWithOverwriteCheck( + skillFile, skillContent, `${skillName}/SKILL.md` + ); + if (!written) { return; } + createdFiles.push(`.claude/skills/${skillName}/SKILL.md`); + + let refs: Array<{ name: string; content: string }>; + try { + refs = await fetchReferenceFiles(skillName); + } catch (err: any) { + errors.push(`${skillName} references: ${err.message}`); + return; + } + + for (const ref of refs) { + try { + const refUri = vscode.Uri.joinPath( + rootUri, `.claude/skills/${skillName}/references/${ref.name}` + ); + await ensureDir(vscode.Uri.joinPath(refUri, "..")); + await vscode.workspace.fs.writeFile(refUri, Buffer.from(ref.content, "utf-8")); + createdFiles.push(`.claude/skills/${skillName}/references/${ref.name}`); + } catch (err: any) { + errors.push(`${skillName}/references/${ref.name}: ${err.message}`); + } + } +} + +export async function installForCursor( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[] +): Promise { + const mdcUri = vscode.Uri.joinPath(rootUri, `.cursor/rules/${skillName}.mdc`); + const written = await writeWithOverwriteCheck( + mdcUri, toMdcContent(skillContent), `${skillName}.mdc` + ); + if (written) { createdFiles.push(`.cursor/rules/${skillName}.mdc`); } +} + +export async function installForCopilot( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[] +): Promise { + const instructionsUri = vscode.Uri.joinPath( + rootUri, ".github/copilot-instructions.md" + ); + await ensureDir(vscode.Uri.joinPath(rootUri, ".github")); + + let existing = ""; + try { + const bytes = await vscode.workspace.fs.readFile(instructionsUri); + existing = Buffer.from(bytes).toString("utf-8"); + } catch { + // new file + } + + if (existing.includes(`## ${skillName}`)) { + if (!createdFiles.includes(".github/copilot-instructions.md")) { + createdFiles.push(".github/copilot-instructions.md"); + } + return; + } + + const body = getBodyAfterFrontmatter(skillContent); + const section = `## ${skillName}\n\n${body}\n`; + const separator = existing.length > 0 ? "\n" : ""; + + await vscode.workspace.fs.writeFile( + instructionsUri, + Buffer.from(existing + separator + section, "utf-8") + ); + + if (!createdFiles.includes(".github/copilot-instructions.md")) { + createdFiles.push(".github/copilot-instructions.md"); + } +} + +export async function installForUniversal( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[], + errors: string[] +): Promise { + const skillFile = vscode.Uri.joinPath( + rootUri, `.agents/skills/${skillName}/SKILL.md` + ); + const written = await writeWithOverwriteCheck( + skillFile, skillContent, `${skillName}/SKILL.md` + ); + if (!written) { return; } + createdFiles.push(`.agents/skills/${skillName}/SKILL.md`); + + let refs: Array<{ name: string; content: string }>; + try { + refs = await fetchReferenceFiles(skillName); + } catch (err: any) { + errors.push(`${skillName} references: ${err.message}`); + return; + } + + for (const ref of refs) { + try { + const refUri = vscode.Uri.joinPath( + rootUri, `.agents/skills/${skillName}/references/${ref.name}` + ); + await ensureDir(vscode.Uri.joinPath(refUri, "..")); + await vscode.workspace.fs.writeFile(refUri, Buffer.from(ref.content, "utf-8")); + createdFiles.push(`.agents/skills/${skillName}/references/${ref.name}`); + } catch (err: any) { + errors.push(`${skillName}/references/${ref.name}: ${err.message}`); + } + } +} + +export async function installForWindsurf( + rootUri: vscode.Uri, + skillName: string, + skillContent: string, + createdFiles: string[], + errors: string[] +): Promise { + const skillFile = vscode.Uri.joinPath( + rootUri, `.windsurf/skills/${skillName}/SKILL.md` + ); + const written = await writeWithOverwriteCheck( + skillFile, skillContent, `${skillName}/SKILL.md` + ); + if (!written) { return; } + createdFiles.push(`.windsurf/skills/${skillName}/SKILL.md`); + + let refs: Array<{ name: string; content: string }>; + try { + refs = await fetchReferenceFiles(skillName); + } catch (err: any) { + errors.push(`${skillName} references: ${err.message}`); + return; + } + + for (const ref of refs) { + try { + const refUri = vscode.Uri.joinPath( + rootUri, `.windsurf/skills/${skillName}/references/${ref.name}` + ); + await ensureDir(vscode.Uri.joinPath(refUri, "..")); + await vscode.workspace.fs.writeFile(refUri, Buffer.from(ref.content, "utf-8")); + createdFiles.push(`.windsurf/skills/${skillName}/references/${ref.name}`); + } catch (err: any) { + errors.push(`${skillName}/references/${ref.name}: ${err.message}`); + } + } +} + +// ── Status detection ────────────────────────────────────────────────────────── + +export async function readInstalledSkillDirNames( + rootUri: vscode.Uri, + platform: PlatformId, + skills: SkillInfo[] +): Promise> { + const installed = new Set(); + + if (platform === "vscode-copilot") { + try { + const uri = vscode.Uri.joinPath(rootUri, ".github/copilot-instructions.md"); + const bytes = await vscode.workspace.fs.readFile(uri); + const content = Buffer.from(bytes).toString("utf-8"); + for (const skill of skills) { + if (content.includes(`## ${skill.name}`)) { + installed.add(skill.dirName); + } + } + } catch { + // file not found β€” nothing installed + } + return installed; + } + + const pathPrefix = + platform === "claude-code" ? ".claude/skills" : + platform === "universal" ? ".agents/skills" : + /* windsurf */ ".windsurf/skills"; + + await Promise.all( + skills.map(async (skill) => { + try { + await vscode.workspace.fs.stat( + vscode.Uri.joinPath(rootUri, `${pathPrefix}/${skill.dirName}/SKILL.md`) + ); + installed.add(skill.dirName); + } catch { + // not installed + } + }) + ); + return installed; +} + +export async function detectActivePlatforms(rootUri: vscode.Uri): Promise { + const checks: Array<{ id: PlatformId; path: string }> = [ + { id: "universal", path: ".agents/skills" }, + { id: "claude-code", path: ".claude/skills" }, + { id: "vscode-copilot", path: ".github/copilot-instructions.md" }, + { id: "windsurf", path: ".windsurf/skills" }, + ]; + const active = new Set([detectEditorPlatform()]); + await Promise.all( + checks.map(async ({ id, path }) => { + try { + await vscode.workspace.fs.stat(vscode.Uri.joinPath(rootUri, path)); + active.add(id); + } catch { /* not present */ } + }) + ); + return [...active]; +} + +export async function readConfiguredMcpServerKeys( + rootUri: vscode.Uri, + mcpFilePath: string, + rootKey: string +): Promise> { + try { + const uri = vscode.Uri.joinPath(rootUri, mcpFilePath); + const bytes = await vscode.workspace.fs.readFile(uri); + const config = JSON.parse(Buffer.from(bytes).toString("utf-8")); + const servers = config[rootKey]; + if (servers && typeof servers === "object") { + return new Set(Object.keys(servers)); + } + } catch { + // file not found or invalid JSON + } + return new Set(); +} + +// ── MCP Server definitions ──────────────────────────────────────────────────── + +export const MCP_SERVERS: McpServerDef[] = [ + { + label: "Cloudinary Asset Management", + description: "Browse, upload, and manage media assets", + key: "cloudinary-asset-mgmt", + config: { url: "https://asset-management.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Environment Config", + description: "Configure upload presets, transformations, and settings", + key: "cloudinary-env-config", + config: { url: "https://environment-config.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Structured Metadata", + description: "Manage structured metadata fields and values", + key: "cloudinary-smd", + config: { url: "https://structured-metadata.mcp.cloudinary.com/mcp" }, + }, + { + label: "Cloudinary Analysis", + description: "AI-powered image and video analysis", + key: "cloudinary-analysis", + config: { url: "https://analysis.mcp.cloudinary.com/sse" }, + }, + { + label: "MediaFlows", + description: "AI-powered media workflows and automation", + key: "mediaflows", + config: { + url: "https://mediaflows.mcp.cloudinary.com/v2/mcp", + headers: { + "cld-cloud-name": "your_cloud_name", + "cld-api-key": "your_api_key", + "cld-secret": "your_api_secret", + }, + }, + }, +]; + +// ── MCP installation helper ─────────────────────────────────────────────────── + +export async function installMcpServers( + rootUri: vscode.Uri, + editor: EditorType, + selectedKeys: string[], + createdFiles: string[] +): Promise { + const mcpFilePath = getMcpFilePath(editor); + const isVscode = editor === "vscode"; + const rootKey = isVscode ? "servers" : "mcpServers"; + const mcpUri = vscode.Uri.joinPath(rootUri, mcpFilePath); + let config: Record = {}; + try { + const bytes = await vscode.workspace.fs.readFile(mcpUri); + config = JSON.parse(Buffer.from(bytes).toString("utf-8")); + } catch { + // new file + } + if (!config[rootKey] || typeof config[rootKey] !== "object") { + config[rootKey] = {}; + } + const servers = config[rootKey] as Record; + for (const key of selectedKeys) { + const def = MCP_SERVERS.find((s) => s.key === key); + if (def) { + servers[def.key] = def.config; + } + } + await ensureDir(vscode.Uri.joinPath(mcpUri, "..")); + await vscode.workspace.fs.writeFile( + mcpUri, + Buffer.from(JSON.stringify(config, null, 2), "utf-8") + ); + if (!createdFiles.includes(mcpFilePath)) { + createdFiles.push(mcpFilePath); + } +} diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts new file mode 100644 index 0000000..f8186f5 --- /dev/null +++ b/src/commands/configureAiTools.ts @@ -0,0 +1,187 @@ +import * as vscode from "vscode"; +import { + EditorType, + McpServerDef, + SkillInfo, + MCP_SERVERS, + detectEditor, + getMcpFilePath, + fetchSkillList, + fetchSkillContent, + installForClaudeCode, + installForCursor, + installForCopilot, + readInstalledSkillDirNames, + readConfiguredMcpServerKeys, + installMcpServers, +} from "../aiToolsService"; + +// ── MCP Config (QuickPick flow) ─────────────────────────────────────────────── + +async function createMcpConfig( + rootUri: vscode.Uri, + editor: EditorType, + mcpFilePath: string, + createdFiles: string[] +): Promise { + const rootKey = editor === "vscode" ? "servers" : "mcpServers"; + const configuredKeys = await readConfiguredMcpServerKeys(rootUri, mcpFilePath, rootKey); + + const selected = await vscode.window.showQuickPick( + MCP_SERVERS.map((s) => ({ + label: s.label, + description: s.description, + detail: configuredKeys.has(s.key) ? "βœ“ already configured" : "Not configured", + picked: !configuredKeys.has(s.key), + })), + { canPickMany: true, placeHolder: "Select MCP servers to configure" } + ); + if (!selected || selected.length === 0) { return; } + + const selectedKeys = selected + .map((item) => MCP_SERVERS.find((s) => s.label === item.label)) + .filter((s): s is McpServerDef => s !== undefined) + .map((s) => s.key); + + await installMcpServers(rootUri, editor, selectedKeys, createdFiles); +} + +// ── Command registration ────────────────────────────────────────────────────── + +function registerConfigureAiTools(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.configureAiTools", async () => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showErrorMessage("Please open a workspace folder first."); + return; + } + const rootUri = workspaceFolders[0].uri; + + // ── Step 1: what to configure ────────────────────────────────────────── + const options = await vscode.window.showQuickPick( + [ + { label: "Skills", description: "Install Cloudinary agent skills", picked: true }, + { label: "MCP Config", description: "Add MCP server configuration file", picked: true }, + ], + { canPickMany: true, placeHolder: "Select what to configure" } + ); + if (!options || options.length === 0) { return; } + + const createdFiles: string[] = []; + const errors: string[] = []; + + // ── Step 2: skills flow ──────────────────────────────────────────────── + if (options.some((o) => o.label === "Skills")) { + let skills: SkillInfo[]; + try { + skills = await fetchSkillList(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to fetch skills: ${err.message}`); + return; + } + + const editor = detectEditor(); + const ideOptions: vscode.QuickPickItem[] = [ + { label: "Claude Code", description: "Install to .claude/skills/" }, + { label: "Cursor", description: "Install to .cursor/rules/" }, + { label: "VS Code (Copilot)", description: "Append to .github/copilot-instructions.md" }, + ]; + const defaultLabel = + editor === "cursor" ? "Cursor" : + editor === "vscode" ? "VS Code (Copilot)" : + "Claude Code"; + + const qp = vscode.window.createQuickPick(); + qp.items = ideOptions; + qp.activeItems = ideOptions.filter((o) => o.label === defaultLabel); + qp.placeholder = "Select AI tool to install skills for"; + + const ideTarget = await new Promise((resolve) => { + qp.onDidAccept(() => { resolve(qp.activeItems[0]); qp.dispose(); }); + qp.onDidHide(() => { resolve(undefined); qp.dispose(); }); + qp.show(); + }); + if (!ideTarget) { return; } + + const platformForStatus: Record = { + "Claude Code": "claude-code", + "VS Code (Copilot)": "vscode-copilot", + }; + const pid = platformForStatus[ideTarget.label]; + const installedDirNames = pid + ? await readInstalledSkillDirNames(rootUri, pid, skills) + : new Set(); + + const pickedSkills = await vscode.window.showQuickPick( + skills.map((s) => ({ + label: s.name, + description: s.description, + detail: installedDirNames.has(s.dirName) ? "βœ“ installed" : "Not installed", + picked: true, + })), + { canPickMany: true, placeHolder: "Select skills to install" } + ); + if (!pickedSkills || pickedSkills.length === 0) { return; } + + for (const item of pickedSkills) { + const skill = skills.find((s) => s.name === item.label); + if (!skill) { continue; } + let content: string; + try { + content = await fetchSkillContent(skill.dirName); + } catch (err: any) { + errors.push(`${skill.dirName}: ${err.message}`); + continue; + } + + if (ideTarget.label === "Claude Code") { + await installForClaudeCode(rootUri, skill.dirName, content, createdFiles, errors); + } else if (ideTarget.label === "Cursor") { + try { + await installForCursor(rootUri, skill.dirName, content, createdFiles); + } catch (err) { + errors.push(`${skill.dirName}: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + try { + await installForCopilot(rootUri, skill.name, content, createdFiles); + } catch (err) { + errors.push(`${skill.name}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + } + + // ── Step 3: MCP config flow ──────────────────────────────────────────── + if (options.some((o) => o.label === "MCP Config")) { + const editor = detectEditor(); + await createMcpConfig(rootUri, editor, getMcpFilePath(editor), createdFiles); + } + + // ── Step 4: feedback ─────────────────────────────────────────────────── + if (errors.length > 0) { + vscode.window.showWarningMessage( + `Some files could not be downloaded: ${errors.join(", ")}` + ); + } + + if (createdFiles.length > 0) { + const action = await vscode.window.showInformationMessage( + `βœ… Configured AI tools: ${createdFiles.join(", ")}`, + "Open File" + ); + if (action === "Open File") { + const doc = await vscode.workspace.openTextDocument( + vscode.Uri.joinPath(rootUri, createdFiles[0]) + ); + vscode.window.showTextDocument(doc); + } + } else if (errors.length === 0) { + vscode.window.showInformationMessage("No files were written β€” all targets already exist."); + } + }) + ); +} + +export default registerConfigureAiTools; diff --git a/src/commands/previewAsset.ts b/src/commands/previewAsset.ts index b80687b..29d208d 100644 --- a/src/commands/previewAsset.ts +++ b/src/commands/previewAsset.ts @@ -119,6 +119,15 @@ function registerPreview(context: vscode.ExtensionContext) { ); } +/** + * Closes all open preview panels when the active environment switches. + */ +export function resetAllPreviewPanels(): void { + for (const panel of openPanels.values()) { + panel.dispose(); + } +} + /** * Get asset type icon. */ diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index 9a50e14..46929f4 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -7,19 +7,36 @@ import registerClipboard from "./copyCommands"; import registerSwitchEnv from "./switchEnvironment"; import registerClearSearch from "./clearSearch"; import registerWelcomeScreen from "./welcomeScreen"; +import registerConfigureAiTools from "./configureAiTools"; import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { HomescreenViewProvider } from "../webview/homescreenView"; /** * Registers all Cloudinary-related commands with the VS Code command registry. * @param context - The extension context. * @param provider - The Cloudinary tree data provider. * @param statusBar - Status bar item to show current environment. + * @param homescreenProvider - The homescreen webview view provider. */ function registerAllCommands( context: vscode.ExtensionContext, provider: CloudinaryTreeDataProvider, - statusBar: vscode.StatusBarItem + statusBar: vscode.StatusBarItem, + homescreenProvider: HomescreenViewProvider ) { + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.showHomescreen", () => { + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "homescreen"); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.showLibrary", () => { + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "library"); + vscode.commands.executeCommand("workbench.view.extension.cloudinary"); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand("cloudinary.refresh", () => provider.refresh({ @@ -31,7 +48,7 @@ function registerAllCommands( ) ); - registerSearch(context, provider); + registerSearch(context, provider, homescreenProvider); registerClearSearch(context, provider); registerViewOptions(context, provider); registerPreview(context); @@ -39,6 +56,7 @@ function registerAllCommands( registerClipboard(context); registerSwitchEnv(context, provider, statusBar); registerWelcomeScreen(context, provider); + registerConfigureAiTools(context); } export { registerAllCommands }; diff --git a/src/commands/searchAssets.ts b/src/commands/searchAssets.ts index d477f5f..f432f34 100644 --- a/src/commands/searchAssets.ts +++ b/src/commands/searchAssets.ts @@ -1,28 +1,20 @@ import * as vscode from "vscode"; import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { HomescreenViewProvider } from "../webview/homescreenView"; /** - * Registers a command that allows users to search for Cloudinary assets by public ID. - * @param context - The VS Code extension context. - * @param provider - Cloudinary tree data provider used to refresh view based on search. + * Registers the search command. Opens the dashboard and focuses its search input. */ function registerSearch( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider + _provider: CloudinaryTreeDataProvider, + homescreenProvider: HomescreenViewProvider ) { context.subscriptions.push( - vscode.commands.registerCommand("cloudinary.searchAssets", async () => { - const query = await vscode.window.showInputBox({ - placeHolder: "Search for a public id", - }); - - if (query !== undefined && query.trim() !== "") { - provider.refresh({ searchQuery: query.trim() }); - } else { - vscode.window.showErrorMessage("Search query cannot be empty."); - } + vscode.commands.registerCommand("cloudinary.searchAssets", () => { + homescreenProvider.focusSearch(); }) ); } -export default registerSearch; \ No newline at end of file +export default registerSearch; diff --git a/src/commands/switchEnvironment.ts b/src/commands/switchEnvironment.ts index 743d2f6..8600975 100644 --- a/src/commands/switchEnvironment.ts +++ b/src/commands/switchEnvironment.ts @@ -82,6 +82,8 @@ function registerSwitchEnv( resourceTypeFilter: 'all' }); + provider.notifyEnvironmentChange(); + vscode.window.showInformationMessage( `πŸ”„ Switched to ${selected} environment.` ); diff --git a/src/commands/uploadWidget.ts b/src/commands/uploadWidget.ts index d283a76..f510c9e 100644 --- a/src/commands/uploadWidget.ts +++ b/src/commands/uploadWidget.ts @@ -74,6 +74,19 @@ function registerUpload( ); } +/** + * Resets the upload panel for a new environment. + * If the panel is currently open, disposes it and reopens it with the new + * credentials already loaded in `provider`. No-ops if the panel is closed. + */ +export function resetUploadPanel(): void { + if (!uploadPanel) { + return; + } + uploadPanel.dispose(); + uploadPanel = undefined; +} + /** * Opens the upload panel or reveals it if already open. */ @@ -213,9 +226,10 @@ function createUploadPanel( provider: CloudinaryTreeDataProvider, context: vscode.ExtensionContext ): vscode.WebviewPanel { + const cloudName = provider.cloudName!; const panel = vscode.window.createWebviewPanel( "cloudinaryUploadWidget", - "Upload to Cloudinary", + `Upload β€” ${cloudName}`, vscode.ViewColumn.One, { enableScripts: true, @@ -233,7 +247,6 @@ function createUploadPanel( ); const currentPreset = provider.getCurrentUploadPreset() || ""; - const cloudName = provider.cloudName!; const folders = collectFolderOptions(provider); const uploadScriptUri = getScriptUri( diff --git a/src/commands/welcomeScreen.ts b/src/commands/welcomeScreen.ts index 83f91fb..c32a18f 100644 --- a/src/commands/welcomeScreen.ts +++ b/src/commands/welcomeScreen.ts @@ -60,23 +60,18 @@ function createWelcomePanel( additionalScripts: [welcomeScriptUri], }); - panel.webview.onDidReceiveMessage((message: { command: string; data?: any }) => { + panel.webview.onDidReceiveMessage((message: { command: string; data?: string; text?: string }) => { switch (message.command) { case "openGlobalConfig": vscode.commands.executeCommand("cloudinary.openGlobalConfig"); break; - case "openUploadWidget": - vscode.commands.executeCommand("cloudinary.openUploadWidget"); - break; - case "switchEnvironment": - vscode.commands.executeCommand("cloudinary.switchEnvironment"); - break; - case "copyToClipboard": - if (message.data) { - vscode.env.clipboard.writeText(message.data); - vscode.window.showInformationMessage("Copied to clipboard!"); + case "copyToClipboard": { + const text = message.text ?? message.data; + if (text) { + vscode.env.clipboard.writeText(text); } break; + } case "openExternal": if (message.data) { vscode.env.openExternal(vscode.Uri.parse(message.data)); @@ -95,334 +90,381 @@ function createWelcomePanel( * Generates the welcome screen body content. */ function getWelcomeContent(provider: CloudinaryTreeDataProvider): string { - const hasConfig = provider.cloudName && provider.apiKey; + const hasConfig = !!(provider.cloudName && provider.apiKey); const cloudName = escapeHtml(provider.cloudName || ""); return ` -
-
-

Welcome to Cloudinary

-

Your Visual Studio Code extension for seamless media management

-
+ + +
+ + +
+
+ + Cloudinary +
+

Welcome to Cloudinary

+

Your media management hub, right inside VS Code β€” let's get you set up.

+
+ +
+ + +
+
+
+
${hasConfig ? `Connected to ${cloudName}` : "No credentials configured"}
+
${hasConfig ? "Your environment is ready. Open the dashboard to explore your media." : "Add your Cloudinary API credentials to get started."}
+ ${hasConfig + ? `` + : `` + } +
- -
-
-

Cloudinary AI & MCP Servers NEW

-

Harness the power of AI-driven media management with Cloudinary's MCP servers

- -
+ + +
-
-
-

What are MCP Servers?

-

MCP servers provide AI assistants like Claude with structured access to Cloudinary's capabilities, enabling:

-
    -
  • Intelligent Asset Management - Upload, search, and organize media through natural language
  • -
  • Smart Transformations - Apply complex image/video transformations with AI assistance
  • -
  • Automated Workflows - Build media pipelines with AI-powered decision making
  • -
  • Content Analysis - Leverage AI for tagging, moderation, and optimization
  • -
+
+
${hasConfig ? "βœ“" : "1"}
+
+
+ Connect your Cloudinary account + ${hasConfig ? 'βœ“ Done' : ""} +
+

+ Add your Cloud Name, API Key, and API Secret to + ~/.cloudinary/environments.json. + Credentials are never stored in VS Code settings β€” they stay in a local file you control. +

+
+ +
+
-
-
-
-

Asset Management

-

Upload and manage images, videos, and raw files.

-
- -
@cloudinary/asset-management
-
-
-
-
-
-

Environment Config

-

Manage upload presets and transformations.

-
- -
@cloudinary/environment-config
-
-
+
+
2
+
+
Explore the Dashboard
+

The Cloudinary sidebar gives you instant access to your media library, upload tools, and AI integrations β€” all without leaving your editor. Open it from the activity bar on the left.

+
+
+
-
-
-

Installation & Setup

-

All MCP servers are installed automatically via NPX. Just add the configuration to your AI client.

- -
- - -
-

Cursor: Settings β†’ Cursor Settings β†’ MCP Tools

-
- -
{
-  "mcpServers": {
-    "cloudinary-asset-mgmt": {
-      "command": "npx",
-      "args": ["-y", "--package", "@cloudinary/asset-management", "--", "mcp", "start"],
-      "env": {
-        "CLOUDINARY_CLOUD_NAME": "your-cloud-name",
-        "CLOUDINARY_API_KEY": "your-api-key",
-        "CLOUDINARY_API_SECRET": "your-api-secret"
-      }
-    }
-  }
-}
-
-
- -
-

Claude Desktop: Settings β†’ Developer β†’ Edit Config

-
- -
{
-  "mcpServers": {
-    "cloudinary-asset-mgmt": {
-      "command": "npx",
-      "args": ["-y", "--package", "@cloudinary/asset-management", "--", "mcp", "start"],
-      "env": {
-        "CLOUDINARY_CLOUD_NAME": "your-cloud-name",
-        "CLOUDINARY_API_KEY": "your-api-key",
-        "CLOUDINARY_API_SECRET": "your-api-secret"
-      }
-    }
-  }
-}
-
-
- -
-

VS Code: Requires GitHub Copilot. Add to MCP config file.

-
- -
"mcp": {
-  "servers": {
-    "cloudinary-asset-mgmt": {
-      "type": "stdio",
-      "command": "npx",
-      "args": ["-y", "--package", "@cloudinary/asset-management", "--", "mcp", "start"],
-      "env": {
-        "CLOUDINARY_CLOUD_NAME": "your-cloud-name",
-        "CLOUDINARY_API_KEY": "your-api-key",
-        "CLOUDINARY_API_SECRET": "your-api-secret"
-      }
-    }
-  }
-}
-
-
-
- -
- Pro Tips: -
    -
  • Add all 4 servers for full functionality, but disable unused ones to save context
  • -
  • Replace the package name for each server
  • -
  • Find your credentials in Console Settings β†’ API Keys
  • -
-
+
+
3
+
+
Browse & manage your media
+

Search and explore assets in the tree view. Preview images and videos, copy delivery URLs, upload new files with drag-and-drop, and manage transformations β€” all from VS Code.

+
+
- -
-
-
-

Configuration Guide

-

Your Cloudinary credentials are stored in an environments.json file for security.

- -

Configuration Location

-
    -
  • Global: ~/.cloudinary/environments.json (recommended)
  • -
  • Project: .cloudinary/environments.json (workspace-specific)
  • -
- -

Configuration Format

-
- -
{
+      
+ + +
+ Configuration file format +
+

+ Create ~/.cloudinary/environments.json with the following structure. + The cloud name is the key. You can define multiple environments. +

+
+ +
{
   "your-cloud-name": {
     "apiKey": "your-api-key",
     "apiSecret": "your-api-secret"
   }
 }
-
- -
- Note: The cloud name is the key (property name). You can optionally add "uploadPreset". -
- -

Finding Your Credentials

-
    -
  1. Go to your Cloudinary Console
  2. -
  3. Navigate to Settings β†’ API Keys
  4. -
  5. Copy your Cloud Name, API Key, and API Secret
  6. -
- -
- - -
-
+

+ Find your credentials at + Console β†’ Settings β†’ API Keys. + You can optionally include uploadPreset for upload configuration. +

+ - -
-
-
-

Resources & Documentation

-
-
-

Getting Started

-
    -
  • Integration Guide
  • -
  • Upload Documentation
  • -
  • Transformations Guide
  • -
  • Video Processing
  • -
-
-
-

AI & MCP Servers

-
    -
  • MCP Servers Guide
  • -
  • Model Context Protocol
  • -
  • Beta Feedback
  • -
-
-
-
-
+
-
-
-

Quick Links

-
-
-
Cloudinary Console
-
console.cloudinary.com
-
-
-
Documentation
-
cloudinary.com/documentation
-
-
-
Support
-
support.cloudinary.com
-
-
-
+ +
+ +
+
+
πŸ“–
+
Documentation
+
Guides, API reference & tutorials
+
+
+
πŸ–₯
+
Console
+
Manage your media & settings online
+
+
+
⬆
+
Upload Guide
+
Learn to upload & organize assets
+
+
+
✨
+
Transformations
+
Resize, crop & optimize media
+
+
+
πŸ’¬
+
Support
+
Get help from the Cloudinary team
+
+
+
🌱
+
Free Account
+
New to Cloudinary? Sign up free
+
+
`; } diff --git a/src/extension.ts b/src/extension.ts index 9f52cab..ab10f7d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,9 @@ import { registerAllCommands } from "./commands/registerCommands"; import { CloudinaryTreeDataProvider } from "./tree/treeDataProvider"; import { v2 as cloudinary } from "cloudinary"; import { generateUserAgent } from "./utils/userAgent"; +import { HomescreenViewProvider } from "./webview/homescreenView"; +import { resetUploadPanel } from "./commands/uploadWidget"; +import { resetAllPreviewPanels } from "./commands/previewAsset"; let statusBar: vscode.StatusBarItem; @@ -39,6 +42,28 @@ function getStatusBarTooltip(dynamicFolders: boolean): string { export async function activate(context: vscode.ExtensionContext) { const cloudinaryProvider = new CloudinaryTreeDataProvider(); + // Set initial view to homescreen + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "homescreen"); + + // Register homescreen sidebar view + const homescreenProvider = new HomescreenViewProvider(context.extensionUri, cloudinaryProvider); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + HomescreenViewProvider.viewType, + homescreenProvider, + { webviewOptions: { retainContextWhenHidden: true } } + ) + ); + + // Refresh all open webviews when the active environment changes. + context.subscriptions.push( + cloudinaryProvider.onDidChangeEnvironment(() => { + homescreenProvider.refresh(); + resetUploadPanel(); + resetAllPreviewPanels(); + }) + ); + // Check if this is the first run of the extension const isFirstRun = context.globalState.get('cloudinary.firstRun', true); @@ -80,7 +105,7 @@ export async function activate(context: vscode.ExtensionContext) { "cloudinaryMediaLibrary", cloudinaryProvider ); - registerAllCommands(context, cloudinaryProvider, statusBar); + registerAllCommands(context, cloudinaryProvider, statusBar, homescreenProvider); return; } @@ -183,6 +208,8 @@ export async function activate(context: vscode.ExtensionContext) { searchQuery: null, resourceTypeFilter: 'all' }); + + cloudinaryProvider.notifyEnvironmentChange(); }); context.subscriptions.push(watcher); @@ -210,7 +237,7 @@ export async function activate(context: vscode.ExtensionContext) { "cloudinaryMediaLibrary", cloudinaryProvider ); - registerAllCommands(context, cloudinaryProvider, statusBar); + registerAllCommands(context, cloudinaryProvider, statusBar, homescreenProvider); } /** diff --git a/src/tree/treeDataProvider.ts b/src/tree/treeDataProvider.ts index 844211f..b7c6250 100644 --- a/src/tree/treeDataProvider.ts +++ b/src/tree/treeDataProvider.ts @@ -38,6 +38,17 @@ export class CloudinaryTreeDataProvider implements vscode.TreeDataProvider(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private _onDidChangeEnvironment = new vscode.EventEmitter(); + readonly onDidChangeEnvironment = this._onDidChangeEnvironment.event; + + /** + * Fires the onDidChangeEnvironment event to notify subscribers that + * credentials have changed to a new environment. + */ + notifyEnvironmentChange(): void { + this._onDidChangeEnvironment.fire(); + } + /** * Refreshes the tree data view. */ diff --git a/src/webview/client/homescreen.ts b/src/webview/client/homescreen.ts new file mode 100644 index 0000000..d5821c6 --- /dev/null +++ b/src/webview/client/homescreen.ts @@ -0,0 +1,378 @@ +import { initCommon, getVSCode } from "./common"; + +// ── Types (mirrored from aiToolsService β€” no import possible in webview client) ── + +interface SkillInfo { + name: string; + description: string; + dirName: string; +} + +interface McpServerInfo { + key: string; + label: string; + description: string; +} + +interface AdditionalPlatform { + id: string; + label: string; + sublabel?: string; + locked: boolean; +} + +interface AiToolsDataMessage { + command: "aiToolsData"; + skills: SkillInfo[]; + primaryPlatform: string; + installedOnPrimary: string[]; + additionalPlatforms: AdditionalPlatform[]; + mcpServers: McpServerInfo[]; + configuredMcpKeys: string[]; + error?: string; +} + +interface AiToolsProgressMessage { + command: "aiToolsProgress"; + item: string; // skill dirName or MCP key + status: "done" | "error"; +} + +interface AiToolsResultMessage { + command: "aiToolsResult"; + errors: string[]; +} + +type InboundMessage = AiToolsDataMessage | AiToolsProgressMessage | AiToolsResultMessage; + +// ── Module state ────────────────────────────────────────────────────────────── + +let _isOpen = false; +let _dataFetched = false; +let _cachedData: Omit | null = null; + +// ── DOM helpers ─────────────────────────────────────────────────────────────── + +function el(id: string): T { + return document.getElementById(id) as T; +} + +function show(id: string): void { + el(id)?.classList.remove("hidden"); +} + +function hide(id: string): void { + el(id)?.classList.add("hidden"); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// ── State rendering ─────────────────────────────────────────────────────────── + +function showPanelState(state: "loading" | "ready" | "done" | "error"): void { + for (const s of ["loading", "ready", "done", "error"] as const) { + const elem = el(`hs-ai-state-${s}`); + if (elem) { + elem.classList.toggle("hidden", s !== state); + } + } +} + +// ── Helper functions ────────────────────────────────────────────────────────── + +// ── Platform rendering ──────────────────────────────────────────────────────── + +const PLATFORM_LABELS: Record = { + "universal": "Universal", + "claude-code": "Claude Code", + "vscode-copilot": "VS Code (Copilot)", + "windsurf": "Windsurf", +}; + +function renderAdditionalPlatformRows(platforms: AdditionalPlatform[]): void { + const list = el("hs-ai-platform-list"); + if (!list) { return; } + list.innerHTML = platforms.map((p) => { + const sublabel = p.sublabel + ? `${escapeHtml(p.sublabel)}` + : ""; + return ``; + }).join(""); + list.querySelectorAll(".hs-ai-platform-cb").forEach((cb) => { + cb.addEventListener("change", updateApplyButton); + }); +} + +// ── Checklist rendering ─────────────────────────────────────────────────────── + +function renderSkillRows(skills: SkillInfo[], installedOnPrimary: string[]): void { + const list = el("hs-ai-skills-list"); + if (!list) { return; } + const installedSet = new Set(installedOnPrimary); + list.innerHTML = skills.map((s) => { + const isInstalled = installedSet.has(s.dirName); + const statusClass = isInstalled ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; + const statusText = isInstalled ? "installed" : "β€”"; + return ``; + }).join(""); + list.querySelectorAll(".hs-ai-cb").forEach((cb) => { + cb.addEventListener("change", updateApplyButton); + }); +} + +function renderMcpRows( + servers: McpServerInfo[], + configuredKeys: string[] +): void { + const list = el("hs-ai-mcp-list"); + if (!list) { return; } + const configuredSet = new Set(configuredKeys); + list.innerHTML = servers + .map((s) => { + const isConfigured = configuredSet.has(s.key); + const statusClass = isConfigured ? "hs-ai-item-status--ok" : "hs-ai-item-status--none"; + const statusText = isConfigured ? "configured" : "β€”"; + return ``; + }) + .join(""); + list.querySelectorAll(".hs-ai-cb").forEach((cb) => { + cb.addEventListener("change", updateApplyButton); + }); +} + +function updateApplyButton(): void { + const applyBtn = el("hs-ai-apply"); + if (!applyBtn) { return; } + const anyActionable = [...document.querySelectorAll(".hs-ai-cb")] + .some((c) => c.checked && !c.disabled); + applyBtn.disabled = !anyActionable; +} + +// ── Accordion toggle ────────────────────────────────────────────────────────── + +function toggleAccordion(): void { + _isOpen = !_isOpen; + + const panel = el("hs-ai-panel"); + const btn = el("hs-btn-ai-tools"); + const chevron = el("hs-ai-chevron"); + + panel.classList.toggle("open", _isOpen); + btn.classList.toggle("expanded", _isOpen); + btn.setAttribute("aria-expanded", String(_isOpen)); + chevron.classList.toggle("hs-chevron--open", _isOpen); + + if (_isOpen && !_dataFetched) { + _dataFetched = true; + showPanelState("loading"); + getVSCode()?.postMessage({ command: "aiToolsExpanded" }); + } +} + +// ── Apply ───────────────────────────────────────────────────────────────────── + +function handleApply(): void { + if (!_cachedData) { return; } + + const skillCheckboxes = document.querySelectorAll(".hs-ai-cb[data-skill]"); + const mcpCheckboxes = document.querySelectorAll(".hs-ai-cb[data-mcp]"); + + const platformCheckboxes = document.querySelectorAll(".hs-ai-cb[data-platform]"); + + const selectedSkills = [...skillCheckboxes].filter((c) => c.checked).map((c) => c.dataset.skill!); + const selectedMcpKeys = [...mcpCheckboxes].filter((c) => c.checked).map((c) => c.dataset.mcp!); + const additionalPlatforms = [...platformCheckboxes].filter((c) => c.checked).map((c) => c.dataset.platform!); + const platforms = [_cachedData.primaryPlatform, ...additionalPlatforms]; + + document.querySelectorAll(".hs-ai-cb").forEach((c) => { c.disabled = true; }); + const applyBtn = el("hs-ai-apply"); + if (applyBtn) { + applyBtn.disabled = true; + applyBtn.textContent = "Applying…"; + } + + getVSCode()?.postMessage({ + command: "installAiTools", + skills: selectedSkills, + platforms, + mcpServers: selectedMcpKeys, + }); +} + +// ── Message handling ────────────────────────────────────────────────────────── + +function handleAiToolsData(msg: AiToolsDataMessage): void { + if (msg.error) { + const errEl = el("hs-ai-error-msg"); + if (errEl) { errEl.textContent = msg.error; } + showPanelState("error"); + return; + } + + _cachedData = { + skills: msg.skills, + primaryPlatform: msg.primaryPlatform, + installedOnPrimary: msg.installedOnPrimary, + additionalPlatforms: msg.additionalPlatforms, + mcpServers: msg.mcpServers, + configuredMcpKeys: msg.configuredMcpKeys, + }; + + const platformLabel = el("hs-ai-skills-platform"); + if (platformLabel) { platformLabel.textContent = `(${PLATFORM_LABELS[msg.primaryPlatform] ?? msg.primaryPlatform})`; } + + renderAdditionalPlatformRows(msg.additionalPlatforms); + renderSkillRows(msg.skills, msg.installedOnPrimary); + renderMcpRows(msg.mcpServers, msg.configuredMcpKeys); + const applyBtn = el("hs-ai-apply"); + if (applyBtn) { applyBtn.textContent = "Apply"; } + showPanelState("ready"); + updateApplyButton(); +} + +function handleAiToolsProgress(msg: AiToolsProgressMessage): void { + // Find the row by data-skill or data-mcp attribute + const cb = document.querySelector( + `[data-skill="${msg.item}"], [data-mcp="${msg.item}"]` + ); + if (!cb) { return; } + + const row = cb.closest(".hs-ai-item"); + if (!row) { return; } + + // Remove existing tick if any + row.querySelector(".hs-ai-item-tick")?.remove(); + + const tick = document.createElement("span"); + tick.className = `hs-ai-item-tick hs-ai-item-tick--${msg.status === "done" ? "ok" : "err"}`; + tick.textContent = msg.status === "done" ? "βœ“" : "βœ•"; + row.appendChild(tick); +} + +function handleAiToolsResult(_msg: AiToolsResultMessage): void { + _dataFetched = false; + _cachedData = null; + showPanelState("loading"); + getVSCode()?.postMessage({ command: "aiToolsExpanded" }); +} + +// ── Search ──────────────────────────────────────────────────────────────────── + +function initSearch(): void { + const input = document.getElementById("hs-search-input") as HTMLInputElement | null; + const clearBtn = document.getElementById("hs-search-clear") as HTMLButtonElement | null; + + if (!input) { return; } + + // Show/hide clear button based on input value + input.addEventListener("input", () => { + if (clearBtn) { + clearBtn.classList.toggle("hidden", input.value.trim() === ""); + } + }); + + // Submit search on Enter + input.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") { + const query = input.value.trim(); + if (query) { + getVSCode()?.postMessage({ command: "searchAssets", data: query }); + } + } + if (e.key === "Escape") { + input.value = ""; + clearBtn?.classList.add("hidden"); + getVSCode()?.postMessage({ command: "clearSearch" }); + } + }); + + // Clear search + clearBtn?.addEventListener("click", () => { + input.value = ""; + clearBtn.classList.add("hidden"); + input.focus(); + getVSCode()?.postMessage({ command: "clearSearch" }); + }); +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +function init(): void { + initCommon(); + + // Standard action buttons (non-accordion) + document.querySelectorAll(".hs-action:not(#hs-btn-ai-tools)").forEach((btn) => { + btn.addEventListener("click", () => { + getVSCode()?.postMessage({ command: btn.dataset.command }); + }); + }); + + // Configure credentials button (setup banner, not hs-action) + document.getElementById("hs-btn-configure")?.addEventListener("click", () => { + getVSCode()?.postMessage({ command: "openGlobalConfig" }); + }); + + // Header configure button (gear icon in cloud row) + document.getElementById("hs-btn-header-configure")?.addEventListener("click", () => { + getVSCode()?.postMessage({ command: "openGlobalConfig" }); + }); + + // Footer links (use data-command like hs-action but have a different class) + document.querySelectorAll(".hs-footer-link[data-command]").forEach((btn) => { + btn.addEventListener("click", () => { + getVSCode()?.postMessage({ command: btn.dataset.command }); + }); + }); + + // Search input + initSearch(); + + // Accordion toggle + el("hs-btn-ai-tools").addEventListener("click", toggleAccordion); + + // Apply button + el("hs-ai-apply")?.addEventListener("click", handleApply); + + // VS Code β†’ webview messages + window.addEventListener("message", (event: MessageEvent) => { + const msg = event.data; + switch (msg.command) { + case "focusSearch": { + const input = document.getElementById("hs-search-input") as HTMLInputElement | null; + if (input) { + input.focus(); + input.select(); + } + break; + } + case "aiToolsData": + handleAiToolsData(msg as AiToolsDataMessage); + break; + case "aiToolsProgress": + handleAiToolsProgress(msg as AiToolsProgressMessage); + break; + case "aiToolsResult": + handleAiToolsResult(msg as AiToolsResultMessage); + break; + } + }); +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/src/webview/client/welcome.ts b/src/webview/client/welcome.ts index 92741ec..533f32c 100644 --- a/src/webview/client/welcome.ts +++ b/src/webview/client/welcome.ts @@ -5,114 +5,28 @@ import { initCommon, getVSCode } from "./common"; /** - * Open global configuration. + * Open global configuration file. */ function openGlobalConfig(): void { getVSCode()?.postMessage({ command: "openGlobalConfig" }); } /** - * Open upload widget. - */ -function openUploadWidget(): void { - getVSCode()?.postMessage({ command: "openUploadWidget" }); -} - -/** - * Switch environment. - */ -function switchEnvironment(): void { - getVSCode()?.postMessage({ command: "switchEnvironment" }); -} - -/** - * Open external URL. + * Open external URL in the default browser. */ function openExternal(url: string): void { getVSCode()?.postMessage({ command: "openExternal", data: url }); } /** - * Focus tree view. + * Focus the Cloudinary sidebar (opens the dashboard). */ function focusTreeView(): void { getVSCode()?.postMessage({ command: "focusTreeView" }); } /** - * Get Cursor MCP config example. - */ -function getCursorConfig(): string { - return JSON.stringify( - { - mcpServers: { - "cloudinary-asset-mgmt": { - command: "npx", - args: ["-y", "--package", "@cloudinary/asset-management", "--", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "your-cloud-name", - CLOUDINARY_API_KEY: "your-api-key", - CLOUDINARY_API_SECRET: "your-api-secret", - }, - }, - }, - }, - null, - 2 - ); -} - -/** - * Get Claude Desktop MCP config example. - */ -function getClaudeConfig(): string { - return JSON.stringify( - { - mcpServers: { - "cloudinary-asset-mgmt": { - command: "npx", - args: ["-y", "--package", "@cloudinary/asset-management", "--", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "your-cloud-name", - CLOUDINARY_API_KEY: "your-api-key", - CLOUDINARY_API_SECRET: "your-api-secret", - }, - }, - }, - }, - null, - 2 - ); -} - -/** - * Get VS Code MCP config example. - */ -function getVSCodeConfig(): string { - return JSON.stringify( - { - mcp: { - servers: { - "cloudinary-asset-mgmt": { - type: "stdio", - command: "npx", - args: ["-y", "--package", "@cloudinary/asset-management", "--", "mcp", "start"], - env: { - CLOUDINARY_CLOUD_NAME: "your-cloud-name", - CLOUDINARY_API_KEY: "your-api-key", - CLOUDINARY_API_SECRET: "your-api-secret", - }, - }, - }, - }, - }, - null, - 2 - ); -} - -/** - * Get environments.json config example. + * Returns the environments.json config example for copying. */ function getConfigExample(): string { return JSON.stringify( @@ -131,25 +45,15 @@ function getConfigExample(): string { declare global { interface Window { openGlobalConfig: typeof openGlobalConfig; - openUploadWidget: typeof openUploadWidget; - switchEnvironment: typeof switchEnvironment; openExternal: typeof openExternal; focusTreeView: typeof focusTreeView; - getCursorConfig: typeof getCursorConfig; - getClaudeConfig: typeof getClaudeConfig; - getVSCodeConfig: typeof getVSCodeConfig; getConfigExample: typeof getConfigExample; } } window.openGlobalConfig = openGlobalConfig; -window.openUploadWidget = openUploadWidget; -window.switchEnvironment = switchEnvironment; window.openExternal = openExternal; window.focusTreeView = focusTreeView; -window.getCursorConfig = getCursorConfig; -window.getClaudeConfig = getClaudeConfig; -window.getVSCodeConfig = getVSCodeConfig; window.getConfigExample = getConfigExample; // Initialize common functionality when this script loads diff --git a/src/webview/homescreenView.ts b/src/webview/homescreenView.ts new file mode 100644 index 0000000..17a4e0c --- /dev/null +++ b/src/webview/homescreenView.ts @@ -0,0 +1,472 @@ +/** + * Homescreen WebviewView provider. + * Renders the minimal dashboard in the Cloudinary sidebar. + */ + +import * as vscode from "vscode"; +import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { createWebviewDocument, getScriptUri, getStyleUri } from "./webviewUtils"; +import { escapeHtml } from "./utils/helpers"; +import { loadEnvironments } from "../config/configUtils"; +import { + PlatformId, + PLATFORMS, + SkillInfo, + MCP_SERVERS, + detectEditor, + getMcpFilePath, + fetchSkillList, + fetchSkillContent, + readInstalledSkillDirNames, + readConfiguredMcpServerKeys, + installForClaudeCode, + installForCopilot, + installForUniversal, + installForWindsurf, + installMcpServers, + detectActivePlatforms, + detectEditorPlatform, +} from "../aiToolsService"; + +export class HomescreenViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = "cloudinaryHomescreen"; + + constructor( + private readonly _extensionUri: vscode.Uri, + private readonly _provider: CloudinaryTreeDataProvider + ) {} + + private _webviewView: vscode.WebviewView | undefined; + private _cachedSkills: SkillInfo[] | undefined; + private _environmentNames: string[] = []; + + private async _loadEnvironments(): Promise { + try { + const envs = await loadEnvironments(); + this._environmentNames = Object.keys(envs); + } catch { + this._environmentNames = []; + } + } + + async resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): Promise { + this._webviewView = webviewView; + await this._loadEnvironments(); + + webviewView.onDidDispose(() => { + this._webviewView = undefined; + }); + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this._extensionUri, "media")], + }; + + const scriptUri = getScriptUri( + webviewView.webview, + this._extensionUri, + "homescreen.js" + ); + const homescreenCssUri = getStyleUri( + webviewView.webview, + this._extensionUri, + "homescreen.css" + ); + + webviewView.webview.html = createWebviewDocument({ + title: "Cloudinary", + webview: webviewView.webview, + extensionUri: this._extensionUri, + bodyContent: this._getBodyContent(), + additionalStyles: [homescreenCssUri], + additionalScripts: [scriptUri], + }); + + webviewView.webview.onDidReceiveMessage( + async (message: { command: string; data?: string; skills?: string[]; platforms?: string[]; mcpServers?: string[] }) => { + switch (message.command) { + case "openGlobalConfig": + vscode.commands.executeCommand("cloudinary.openGlobalConfig"); + break; + case "showLibrary": + vscode.commands.executeCommand("cloudinary.showLibrary"); + break; + case "openUploadWidget": + vscode.commands.executeCommand("cloudinary.openUploadWidget"); + break; + case "openWelcomeScreen": + vscode.commands.executeCommand("cloudinary.openWelcomeScreen"); + break; + case "searchAssets": + if (message.data?.trim()) { + this._provider.refresh({ searchQuery: message.data.trim() }); + vscode.commands.executeCommand("cloudinary.showLibrary"); + } + break; + case "clearSearch": + this._provider.refresh({ searchQuery: null }); + break; + case "switchEnvironment": + vscode.commands.executeCommand("cloudinary.switchEnvironment"); + break; + case "aiToolsExpanded": + await this._handleAiToolsExpanded(); + break; + case "installAiTools": + await this._handleInstallAiTools( + message.skills ?? [], + message.platforms ?? [], + message.mcpServers ?? [] + ); + break; + } + } + ); + } + + /** + * Switches to the homescreen view and moves keyboard focus to the search input. + */ + focusSearch(): void { + // Set context first so the homescreen view's `when` condition becomes true + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "homescreen"); + // Then bring the sidebar into focus (mirrors what showLibrary does) + vscode.commands.executeCommand("workbench.view.extension.cloudinary"); + // Post focusSearch after the view has had time to become visible + setTimeout(() => { + this._webviewView?.webview.postMessage({ command: "focusSearch" }); + }, 250); + } + + /** + * Re-renders the homescreen HTML with current credentials. + * Safe to call at any time; no-ops if the view has not been resolved yet. + */ + async refresh(): Promise { + if (!this._webviewView) { + return; + } + await this._loadEnvironments(); + const scriptUri = getScriptUri( + this._webviewView.webview, + this._extensionUri, + "homescreen.js" + ); + const homescreenCssUri = getStyleUri( + this._webviewView.webview, + this._extensionUri, + "homescreen.css" + ); + this._webviewView.webview.html = createWebviewDocument({ + title: "Cloudinary", + webview: this._webviewView.webview, + extensionUri: this._extensionUri, + bodyContent: this._getBodyContent(), + additionalStyles: [homescreenCssUri], + additionalScripts: [scriptUri], + }); + } + + private _getBodyContent(): string { + const hasConfig = !!(this._provider.cloudName && this._provider.apiKey); + const cloudName = escapeHtml(this._provider.cloudName || ""); + const folderMode = this._provider.dynamicFolders ? "Dynamic folders" : "Fixed folders"; + + return ` + +
+
+
+
+ + Cloudinary +
+ + + ${hasConfig ? "Connected" : "Setup needed"} + +
+
+
+ ${hasConfig ? cloudName : "Not configured"} + ${hasConfig ? `${folderMode}` : ""} +
+ +
+
+ + ${!hasConfig ? ` +
+ ⚠ + Add your API credentials to connect + +
+ ` : ""} + + ${hasConfig ? ` + + ` : ""} + +
+ + + + + ${this._environmentNames.length > 1 ? ` + + ` : ""} + + + + + + +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + +
+
+ + +
+ `; + } + + private async _handleAiToolsExpanded(): Promise { + const view = this._webviewView; + if (!view) { return; } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + view.webview.postMessage({ + command: "aiToolsData", + error: "Please open a workspace folder first.", + }); + return; + } + const rootUri = workspaceFolders[0].uri; + + try { + if (!this._cachedSkills) { + this._cachedSkills = await fetchSkillList(); + } + const skills = this._cachedSkills; + + const primaryPlatform = detectEditorPlatform(); + const [installedOnPrimarySet, activePlatforms] = await Promise.all([ + readInstalledSkillDirNames(rootUri, primaryPlatform, skills), + detectActivePlatforms(rootUri), + ]); + + const additionalPlatforms = PLATFORMS + .filter((p) => p.id !== primaryPlatform) + .map((p) => ({ + id: p.id, + label: p.label, + sublabel: p.sublabel, + locked: activePlatforms.includes(p.id as PlatformId), + })); + + const editor = detectEditor(); + const mcpFilePath = getMcpFilePath(editor); + const rootKey = editor === "vscode" ? "servers" : "mcpServers"; + const configuredMcpSet = await readConfiguredMcpServerKeys(rootUri, mcpFilePath, rootKey); + + view.webview.postMessage({ + command: "aiToolsData", + skills: skills.map((s) => ({ name: s.name, description: s.description, dirName: s.dirName })), + primaryPlatform, + installedOnPrimary: [...installedOnPrimarySet], + additionalPlatforms, + mcpServers: MCP_SERVERS.map((s) => ({ key: s.key, label: s.label, description: s.description })), + configuredMcpKeys: [...configuredMcpSet], + }); + } catch (err: any) { + view.webview.postMessage({ + command: "aiToolsData", + error: err.message ?? String(err), + }); + } + } + + private async _handleInstallAiTools( + skills: string[], + platforms: string[], + mcpServers: string[] + ): Promise { + const view = this._webviewView; + if (!view) { return; } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + view.webview.postMessage({ command: "aiToolsResult", errors: ["No workspace folder open."] }); + return; + } + const rootUri = workspaceFolders[0].uri; + const errors: string[] = []; + const cachedSkills = this._cachedSkills ?? []; + + for (const dirName of skills) { + const skillInfo = cachedSkills.find((s) => s.dirName === dirName); + if (!skillInfo) { continue; } + + let content: string; + try { + content = await fetchSkillContent(dirName); + } catch (err: any) { + errors.push(`${dirName}: ${err.message}`); + view.webview.postMessage({ command: "aiToolsProgress", item: dirName, status: "error" }); + continue; + } + + const createdFiles: string[] = []; + let anyError = false; + for (const platform of platforms) { + const errsBefore = errors.length; + try { + if (platform === "claude-code") { + await installForClaudeCode(rootUri, dirName, content, createdFiles, errors); + } else if (platform === "universal") { + await installForUniversal(rootUri, dirName, content, createdFiles, errors); + } else if (platform === "windsurf") { + await installForWindsurf(rootUri, dirName, content, createdFiles, errors); + } else if (platform === "vscode-copilot") { + await installForCopilot(rootUri, skillInfo.name, content, createdFiles); + } + } catch (err: any) { + errors.push(`${dirName} (${platform}): ${err.message}`); + anyError = true; + } + if (errors.length > errsBefore) { + anyError = true; + } + } + view.webview.postMessage({ + command: "aiToolsProgress", + item: dirName, + status: anyError ? "error" : "done", + }); + } + + if (mcpServers.length > 0) { + const editor = detectEditor(); + const createdFiles: string[] = []; + try { + await installMcpServers(rootUri, editor, mcpServers, createdFiles); + for (const key of mcpServers) { + view.webview.postMessage({ command: "aiToolsProgress", item: key, status: "done" }); + } + } catch (err: any) { + errors.push(`MCP: ${err.message}`); + for (const key of mcpServers) { + view.webview.postMessage({ command: "aiToolsProgress", item: key, status: "error" }); + } + } + } + + this._cachedSkills = undefined; + view.webview.postMessage({ command: "aiToolsResult", errors }); + } +} diff --git a/src/webview/scripts/welcomeScreen.ts b/src/webview/scripts/welcomeScreen.ts index 75047de..30994c2 100644 --- a/src/webview/scripts/welcomeScreen.ts +++ b/src/webview/scripts/welcomeScreen.ts @@ -8,26 +8,10 @@ */ export function getWelcomeScreenScript(): string { return ` - // ======================================== - // Command Functions - // ======================================== - function openGlobalConfig() { vscode.postMessage({ command: 'openGlobalConfig' }); } - function openUploadWidget() { - vscode.postMessage({ command: 'openUploadWidget' }); - } - - function switchEnvironment() { - vscode.postMessage({ command: 'switchEnvironment' }); - } - - function copyToClipboard(text) { - vscode.postMessage({ command: 'copyToClipboard', data: text }); - } - function openExternal(url) { vscode.postMessage({ command: 'openExternal', data: url }); } @@ -36,61 +20,6 @@ export function getWelcomeScreenScript(): string { vscode.postMessage({ command: 'focusTreeView' }); } - // ======================================== - // Configuration Generators - // ======================================== - - function getCursorConfig() { - return JSON.stringify({ - "mcpServers": { - "cloudinary-asset-mgmt": { - "command": "npx", - "args": ["-y", "--package", "@cloudinary/asset-management", "--", "mcp", "start"], - "env": { - "CLOUDINARY_CLOUD_NAME": "your-cloud-name", - "CLOUDINARY_API_KEY": "your-api-key", - "CLOUDINARY_API_SECRET": "your-api-secret" - } - } - } - }, null, 2); - } - - function getClaudeConfig() { - return JSON.stringify({ - "mcpServers": { - "cloudinary-asset-mgmt": { - "command": "npx", - "args": ["-y", "--package", "@cloudinary/asset-management", "--", "mcp", "start"], - "env": { - "CLOUDINARY_CLOUD_NAME": "your-cloud-name", - "CLOUDINARY_API_KEY": "your-api-key", - "CLOUDINARY_API_SECRET": "your-api-secret" - } - } - } - }, null, 2); - } - - function getVSCodeConfig() { - return JSON.stringify({ - "mcp": { - "servers": { - "cloudinary-asset-mgmt": { - "type": "stdio", - "command": "npx", - "args": ["-y", "--package", "@cloudinary/asset-management", "--", "mcp", "start"], - "env": { - "CLOUDINARY_CLOUD_NAME": "your-cloud-name", - "CLOUDINARY_API_KEY": "your-api-key", - "CLOUDINARY_API_SECRET": "your-api-secret" - } - } - } - } - }, null, 2); - } - function getConfigExample() { return JSON.stringify({ "your-cloud-name": { diff --git a/src/webview/webviewUtils.ts b/src/webview/webviewUtils.ts index 02263c4..f74c620 100644 --- a/src/webview/webviewUtils.ts +++ b/src/webview/webviewUtils.ts @@ -69,6 +69,19 @@ export function getScriptUri( ]); } +/** + * Get a stylesheet URI from media/styles/. + */ +export function getStyleUri( + webview: vscode.Webview, + extensionUri: vscode.Uri, + cssName: string +): vscode.Uri { + return getWebviewUri(webview, extensionUri, [ + "media", "styles", cssName + ]); +} + /** * Generate Content Security Policy for a webview. */ @@ -92,6 +105,7 @@ export interface WebviewDocumentOptions { extensionUri: vscode.Uri; bodyContent: string; bodyClass?: string; + additionalStyles?: vscode.Uri[]; additionalScripts?: vscode.Uri[]; inlineScript?: string; } @@ -106,6 +120,7 @@ export function createWebviewDocument(options: WebviewDocumentOptions): string { extensionUri, bodyContent, bodyClass = "", + additionalStyles = [], additionalScripts = [], inlineScript = "", } = options; @@ -114,6 +129,10 @@ export function createWebviewDocument(options: WebviewDocumentOptions): string { const { tokensUri, baseUri, componentsUri } = getMediaUris(webview, extensionUri); const csp = getCSP(webview, nonce); + const extraStyleTags = additionalStyles + .map((uri) => ` `) + .join("\n"); + const scriptTags = additionalScripts .map((uri) => ``) .join("\n "); @@ -131,6 +150,7 @@ export function createWebviewDocument(options: WebviewDocumentOptions): string { +${extraStyleTags} ${escapeHtml(title)}