diff --git a/README.md b/README.md index 488a0cb..5f1ed41 100644 --- a/README.md +++ b/README.md @@ -128,13 +128,15 @@ Once a valid configuration has been added, the active environment will be shown ## Contribute -Got feedback or feature ideas? Open an issue. +Got feedback or feature ideas? See [CONTRIBUTING.md](./CONTRIBUTING.md) or open an issue. --- ## Resources - [Cloudinary Documentation](https://cloudinary.com/documentation) +- [Cloudinary Upload Presets](https://cloudinary.com/documentation/upload_presets) +- [Cloudinary Admin API](https://cloudinary.com/documentation/admin_api) --- diff --git a/docs/adding-features.md b/docs/adding-features.md new file mode 100644 index 0000000..5439ff0 --- /dev/null +++ b/docs/adding-features.md @@ -0,0 +1,363 @@ +# Adding Features + +This guide explains how to add new functionality to the Cloudinary VS Code Extension. + +## Adding a New Command + +### 1. Create the Command File + +Create a new file in `src/commands/`: + +```typescript +// src/commands/myNewCommand.ts +import * as vscode from "vscode"; +import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; + +function registerMyNewCommand( + context: vscode.ExtensionContext, + provider: CloudinaryTreeDataProvider +) { + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.myNewCommand", async () => { + // Implementation here + vscode.window.showInformationMessage("Command executed!"); + }) + ); +} + +export default registerMyNewCommand; +``` + +### 2. Register the Command + +Add to `src/commands/registerCommands.ts`: + +```typescript +import registerMyNewCommand from "./myNewCommand"; + +function registerAllCommands( + context: vscode.ExtensionContext, + provider: CloudinaryTreeDataProvider +) { + // ... existing registrations + registerMyNewCommand(context, provider); +} +``` + +### 3. Add to Package.json + +```json +{ + "contributes": { + "commands": [ + { + "command": "cloudinary.myNewCommand", + "title": "My New Command", + "category": "Cloudinary", + "icon": "$(symbol-misc)" + } + ] + } +} +``` + +### 4. Add Menu Placement (Optional) + +```json +{ + "contributes": { + "menus": { + "view/title": [ + { + "command": "cloudinary.myNewCommand", + "when": "view == cloudinaryMediaLibrary", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "cloudinary.myNewCommand", + "when": "viewItem == asset", + "group": "inline" + } + ] + } + } +} +``` + +## Adding a New Tree Item Type + +### 1. Add the Type + +In `src/tree/cloudinaryItem.ts`: + +```typescript +export type CloudinaryItemType = + | 'asset' + | 'folder' + | 'loadMore' + | 'myNewType'; // Add your type +``` + +### 2. Handle in Constructor + +```typescript +else if (type === 'myNewType') { + this.contextValue = 'myNewType'; + this.iconPath = new vscode.ThemeIcon('symbol-misc'); + this.tooltip = 'My new item type'; + this.command = { + command: 'cloudinary.handleMyNewType', + title: 'Handle', + arguments: [data], + }; +} +``` + +### 3. Create Items in Provider + +In `src/tree/treeDataProvider.ts`: + +```typescript +const myItem = new CloudinaryItem( + 'Item Label', + vscode.TreeItemCollapsibleState.None, + 'myNewType', + { customData: 'value' }, + this.cloudName!, + this.dynamicFolders +); +items.push(myItem); +``` + +### 4. Add Context Menu (Optional) + +```json +{ + "contributes": { + "menus": { + "view/item/context": [ + { + "command": "cloudinary.myCommand", + "when": "viewItem == myNewType" + } + ] + } + } +} +``` + +## Adding a New Webview + +### 1. Create the Command File + +```typescript +// src/commands/myWebview.ts +import * as vscode from "vscode"; +import { createWebviewDocument, getScriptUri } from "../webview/webviewUtils"; +import { escapeHtml } from "../webview/utils/helpers"; + +let panel: vscode.WebviewPanel | undefined; + +function registerMyWebview(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.openMyWebview", () => { + if (panel) { + panel.reveal(); + return; + } + + panel = vscode.window.createWebviewPanel( + "cloudinaryMyWebview", + "My Webview", + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(context.extensionUri, "src", "webview", "media"), + ], + } + ); + + panel.webview.html = createWebviewDocument({ + title: "My Webview", + webview: panel.webview, + extensionUri: context.extensionUri, + bodyContent: getContent(), + inlineScript: "initCommon();", + }); + + panel.webview.onDidReceiveMessage((message) => { + switch (message.command) { + case "doSomething": + // Handle message + break; + } + }); + + panel.onDidDispose(() => { + panel = undefined; + }); + }) + ); +} + +function getContent(): string { + return ` +
+
+
+

My Webview

+

Content here

+ +
+
+
+ `; +} + +export default registerMyWebview; +``` + +### 2. Add Custom JavaScript (Optional) + +Create `src/webview/media/scripts/my-webview.js`: + +```javascript +/** + * My Webview functionality. + */ + +function initMyWebview() { + const button = document.getElementById('myButton'); + if (button) { + button.addEventListener('click', () => { + vscode.postMessage({ command: 'doSomething' }); + }); + } +} +``` + +Update the webview to include it: + +```typescript +const myScriptUri = getScriptUri(panel.webview, context.extensionUri, "my-webview.js"); + +panel.webview.html = createWebviewDocument({ + // ... + additionalScripts: [myScriptUri], + inlineScript: "initCommon(); initMyWebview();", +}); +``` + +### 3. Register and Add to Package.json + +Same as adding a command (see above). + +## Adding Configuration Options + +### 1. Update Configuration Interface + +In `src/config/configUtils.ts`: + +```typescript +export interface CloudinaryEnvironment { + apiKey: string; + apiSecret: string; + uploadPreset?: string; + myNewOption?: string; // Add your option +} +``` + +### 2. Use in Code + +```typescript +const myOption = provider.getConfig().myNewOption || 'default'; +``` + +### 3. Document the Option + +Update `docs/configuration.md` with the new option. + +## Common Patterns + +### Error Handling + +```typescript +import { handleCloudinaryError } from '../utils/cloudinaryErrorHandler'; + +try { + const result = await cloudinary.api.someMethod(); +} catch (err: any) { + handleCloudinaryError('Operation failed', err); +} +``` + +### User Input + +```typescript +// Input box +const query = await vscode.window.showInputBox({ + placeHolder: 'Enter search term', + prompt: 'Search assets by public ID', + validateInput: (value) => value ? null : 'Cannot be empty' +}); + +if (!query) return; // User cancelled + +// Quick pick +const selected = await vscode.window.showQuickPick( + ['Option 1', 'Option 2', 'Option 3'], + { placeHolder: 'Select an option' } +); +``` + +### Progress Indicator + +```typescript +await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Loading assets...', + cancellable: false, + }, + async (progress) => { + progress.report({ increment: 0 }); + // Do work + progress.report({ increment: 50 }); + // More work + progress.report({ increment: 100 }); + } +); +``` + +### Refresh Tree View + +```typescript +// After modifying data +provider.refresh(); // Fires _onDidChangeTreeData +``` + +## Testing Your Changes + +1. **Compile**: `npm run compile` +2. **Launch**: Press `F5` to open Extension Development Host +3. **Test**: Verify functionality works as expected +4. **Reload**: Press `Ctrl+R` / `Cmd+R` after code changes + +### Manual Testing Checklist + +- [ ] Feature works in light and dark themes +- [ ] Error cases show user-friendly messages +- [ ] Keyboard navigation works +- [ ] No console errors in Developer Tools + +## Code Style Guidelines + +1. **TypeScript strict mode** - Fix all type errors +2. **Use existing patterns** - Follow conventions in similar files +3. **Escape HTML** - Always use `escapeHtml()` for dynamic content +4. **Use design system** - Use component classes from `components.css` +5. **Handle errors** - Use `handleCloudinaryError()` for API errors +6. **Document** - Add JSDoc comments for public functions diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..015952a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,140 @@ +# Architecture + +The extension is built on VS Code's extension API and integrates with Cloudinary's Admin and Upload APIs. + +### Core Components + +``` +┌─────────────────────────────────────────────────────────────┐ +│ VS Code Extension │ +├─────────────────────────────────────────────────────────────┤ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Tree View │ │ Webviews │ │ Commands │ │ +│ │ (Sidebar) │ │ (Panels) │ │ (Actions) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ └────────────┬────┴────────────────┘ │ +│ │ │ +│ ┌────────────▼────────────┐ │ +│ │ CloudinaryTreeProvider │ │ +│ │ (State & API Layer) │ │ +│ └────────────┬────────────┘ │ +│ │ │ +├──────────────────────┼──────────────────────────────────────┤ +│ │ │ +│ ┌────────────▼────────────┐ │ +│ │ Cloudinary SDK │ │ +│ │ (Node.js v2) │ │ +│ └────────────┬────────────┘ │ +│ │ │ +└──────────────────────┼──────────────────────────────────────┘ + │ + ┌───────────▼───────────┐ + │ Cloudinary APIs │ + │ - Admin API │ + │ - Search API │ + │ - Upload API │ + └───────────────────────┘ +``` + +### Key Design Decisions + +1. **Configuration via files, not settings** - API secrets are stored in `~/.cloudinary/environments.json`, not VS Code settings (which could be committed to repos) + +2. **Singleton provider** - `CloudinaryTreeDataProvider` holds all state and manages API calls + +3. **External CSS/JS for webviews** - Styles and scripts are loaded from files, not embedded in HTML strings + +4. **Centralized icons** - SVG icons are defined once in `src/webview/icons.ts` + +5. **Shared utilities** - Common functions like `escapeHtml` are in `src/webview/utils/helpers.ts` + +## Technology Stack + +| Component | Technology | +|-----------|------------| +| Language | TypeScript (strict mode) | +| Build | esbuild | +| Runtime | VS Code Extension Host (Node.js) | +| API Client | Cloudinary Node.js SDK v2.x | +| Testing | Mocha + VS Code Test Electron | +| Linting | ESLint | + +## Data Flow + +### Configuration Loading + +``` +1. Extension activates +2. Check for workspace config (.cloudinary/environments.json) +3. Fall back to global config (~/.cloudinary/environments.json) +4. Validate credentials (reject placeholder values) +5. Configure Cloudinary SDK +6. Detect folder mode (dynamic vs fixed) +``` + +### Tree View Population + +``` +1. VS Code calls provider.getChildren() +2. Provider checks cache (assetMap) +3. If not cached, fetch from API +4. Transform to CloudinaryItem instances +5. Return items to VS Code +``` + +### Webview Communication + +``` +Extension Webview + │ │ + │ panel.webview.html = ... │ + │──────────────────────────────────>│ + │ │ + │ vscode.postMessage({...}) │ + │<──────────────────────────────────│ + │ │ + │ panel.webview.postMessage({...})│ + │──────────────────────────────────>│ + │ │ +``` + +## VS Code Integration Points + +### Package.json Contributions + +| Contribution | Purpose | +|--------------|---------| +| `viewsContainers.activitybar` | Cloudinary icon in sidebar | +| `views.cloudinary` | Tree view registration | +| `commands` | Command definitions | +| `menus.view/title` | Tree view title bar buttons | +| `menus.view/item/context` | Right-click context menu | + +### Tree Item Context Values + +| Context Value | Description | +|---------------|-------------| +| `asset` | Media file (enables copy commands) | +| `folder` | Directory (enables upload to folder) | +| `loadMore` | Pagination trigger | +| `clearSearch` | Clear search results | + +## Error Handling + +All Cloudinary API errors flow through `handleCloudinaryError()`: + +```typescript +import { handleCloudinaryError } from '../utils/cloudinaryErrorHandler'; + +try { + await cloudinary.search.execute(); +} catch (err: any) { + handleCloudinaryError('Failed to search assets', err); +} +``` + +The handler: +1. Extracts message from various error formats +2. Shows VS Code error notification +3. Offers "Open Global Config" action for credential errors diff --git a/docs/project-structure.md b/docs/project-structure.md new file mode 100644 index 0000000..4de1ddd --- /dev/null +++ b/docs/project-structure.md @@ -0,0 +1,220 @@ +# Project Structure + +This document explains the organization of the Cloudinary VS Code Extension codebase. + +## Directory Overview + +``` +cloudinary-vscode/ +├── src/ # TypeScript source code +│ ├── extension.ts # Extension entry point +│ ├── commands/ # Command implementations +│ ├── tree/ # Tree view (sidebar) +│ ├── config/ # Configuration utilities +│ ├── utils/ # Shared utilities +│ ├── webview/ # Webview design system +│ └── test/ # Test files +├── dist/ # Bundled output (esbuild) +├── out/ # TypeScript output (for tests) +├── docs/ # Documentation +├── resources/ # Static assets (icons) +├── package.json # Extension manifest +├── tsconfig.json # TypeScript configuration +├── esbuild.js # Build script +└── .eslintrc.json # ESLint configuration +``` + +## Source Code (`src/`) + +### Entry Point + +**`extension.ts`** - Extension lifecycle management + +- `activate()` - Called when extension starts +- Creates `CloudinaryTreeDataProvider` +- Loads configuration +- Registers commands and tree view + +### Commands (`src/commands/`) + +Each file exports a registration function: + +| File | Commands | Purpose | +|------|----------|---------| +| `registerCommands.ts` | - | Central registration, imports all commands | +| `previewAsset.ts` | `cloudinary.openAsset` | Asset preview panel | +| `uploadWidget.ts` | `cloudinary.openUploadWidget`, `cloudinary.uploadToFolder` | Upload panel | +| `welcomeScreen.ts` | `cloudinary.showWelcome` | Welcome/onboarding screen | +| `searchAssets.ts` | `cloudinary.searchAssets` | Search by public ID | +| `copyCommands.ts` | `cloudinary.copyPublicId`, `cloudinary.copySecureUrl` | Clipboard operations | +| `switchEnvironment.ts` | `cloudinary.switchEnvironment` | Environment switching | +| `clearSearch.ts` | `cloudinary.clearSearch` | Clear search filter | +| `viewOptions.ts` | `cloudinary.setResourceFilter` | Filter by type | + +### Tree View (`src/tree/`) + +| File | Purpose | +|------|---------| +| `treeDataProvider.ts` | `TreeDataProvider` implementation, state management, API calls | +| `cloudinaryItem.ts` | `TreeItem` subclass for assets, folders, and UI elements | + +**CloudinaryTreeDataProvider** holds: +- Credentials (`cloudName`, `apiKey`, `apiSecret`) +- View state (current folder, search query, filter) +- Asset cache (`assetMap`) +- Upload presets + +### Configuration (`src/config/`) + +| File | Purpose | +|------|---------| +| `configUtils.ts` | Load/validate configuration files | +| `detectFolderMode.ts` | Detect dynamic vs fixed folder mode | + +### Utilities (`src/utils/`) + +| File | Purpose | +|------|---------| +| `cloudinaryErrorHandler.ts` | Consistent error display with VS Code UI | +| `userAgent.ts` | Generate user agent for API calls | + +### Webview System (`src/webview/`) + +The webview module provides a design system for building consistent UIs: + +``` +src/webview/ +├── index.ts # Public exports +├── tokens.ts # Design tokens (colors, spacing) +├── baseStyles.ts # CSS reset, typography +├── icons.ts # Centralized SVG icons +├── webviewUtils.ts # HTML generation helpers +├── components/ # UI components +│ ├── index.ts # Component exports +│ ├── button.ts # Button styles +│ ├── card.ts # Card/panel styles +│ ├── tabs.ts # Tab navigation +│ ├── input.ts # Form inputs +│ ├── dropZone.ts # File upload drop zone +│ ├── progressBar.ts # Progress indicators +│ ├── badge.ts # Tags and badges +│ ├── infoRow.ts # Key-value display +│ ├── lightbox.ts # Image lightbox +│ └── layout.ts # Layout components +├── utils/ # Webview utilities +│ ├── index.ts # Utility exports +│ ├── helpers.ts # escapeHtml, formatFileSize, etc. +│ ├── clipboard.ts # Clipboard functionality +│ └── messaging.ts # VS Code API wrappers +├── scripts/ # TypeScript for client-side JS +│ ├── index.ts +│ ├── uploadWidget.ts +│ ├── previewAsset.ts +│ └── welcomeScreen.ts +└── media/ # External CSS/JS files + ├── styles/ + │ ├── tokens.css # CSS custom properties + │ ├── base.css # Base styles + │ └── components.css # Component styles + └── scripts/ + ├── common.js # Shared client-side utilities + ├── upload-widget.js # Upload panel functionality + └── welcome.js # Welcome screen functionality +``` + +See [Webview System](./webview-system.md) for detailed documentation. + +## Configuration Files + +### `package.json` + +Extension manifest defining: +- Extension metadata (name, version, publisher) +- Activation events +- Contributed commands, views, and menus +- Dependencies + +### `tsconfig.json` + +TypeScript configuration: +- Strict mode enabled +- ES2022 target +- CommonJS modules (for VS Code) + +### `esbuild.js` + +Build configuration: +- Entry: `src/extension.ts` +- Output: `dist/extension.js` +- Externals: `vscode` module +- Source maps enabled + +## Output Directories + +### `dist/` + +Production bundle created by esbuild: +- `extension.js` - Bundled extension code +- `extension.js.map` - Source map + +### `out/` + +TypeScript compilation output (for tests): +- Mirrors `src/` structure +- Used by test runner + +## Resources + +### `resources/` + +Static assets: +- `cloudinary_icon_blue.png` - Extension icon +- `icon-image.svg`, `icon-video.svg`, `icon-file.svg` - Tree item icons + +## Key Patterns + +### Command Registration + +```typescript +// src/commands/myCommand.ts +function registerMyCommand( + context: vscode.ExtensionContext, + provider: CloudinaryTreeDataProvider +) { + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.myCommand", async () => { + // Implementation + }) + ); +} +export default registerMyCommand; +``` + +### Tree Item Creation + +```typescript +new CloudinaryItem( + 'Label', + vscode.TreeItemCollapsibleState.Collapsed, + 'folder', // type + { path: '/products' }, // data + cloudName, + dynamicFolders +); +``` + +### Webview HTML Generation + +```typescript +import { createWebviewDocument, getScriptUri } from "../webview/webviewUtils"; + +panel.webview.html = createWebviewDocument({ + title: "My Panel", + webview: panel.webview, + extensionUri: context.extensionUri, + bodyContent: getHtmlContent(), + additionalScripts: [getScriptUri(webview, extensionUri, "my-script.js")], + inlineScript: "initCommon(); initMyPanel();", +}); +``` + diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..903904a --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,150 @@ +# Development Setup + +This guide will help you set up your development environment for contributing to the Cloudinary VS Code Extension. + +## Prerequisites + +- **Node.js** 18.x or later +- **npm** 8.x or later +- **Visual Studio Code** 1.85.0 or later +- **Git** +- **Cloudinary account** with API credentials + +## Clone and Install + +```bash +# Clone the repository +git clone https://github.com/cloudinary/cloudinary-vscode.git +cd cloudinary-vscode + +# Install dependencies +npm install +``` + +## Configure Credentials + +Create a Cloudinary configuration file: + +**macOS/Linux:** +```bash +mkdir -p ~/.cloudinary +``` + +**Windows:** +```powershell +mkdir $env:USERPROFILE\.cloudinary +``` + +Create `environments.json` with your credentials: + +```json +{ + "your-cloud-name": { + "apiKey": "your-api-key", + "apiSecret": "your-api-secret" + } +} +``` + +## Build Commands + +| Command | Description | +|---------|-------------| +| `npm run compile` | Type check + build | +| `npm run watch` | Watch mode (auto-rebuild) | +| `npm run check-types` | TypeScript type checking only | +| `npm run lint` | Run ESLint | +| `npm test` | Run tests | + +## Running the Extension + +### Launch Extension Development Host + +1. Open the project in VS Code +2. Press `F5` (or Run → Start Debugging) +3. A new VS Code window opens with your extension loaded +4. Make changes, then press `Ctrl+R` / `Cmd+R` in the dev window to reload + +### Setting Breakpoints + +1. Set breakpoints in `.ts` files (source maps are enabled) +2. Launch the Extension Development Host +3. Trigger the code path you want to debug +4. VS Code will pause at your breakpoints + +## Testing + +### Run All Tests + +```bash +npm test +``` + +### Manual Testing Checklist + +After making changes, verify: + +- [ ] Extension activates without errors +- [ ] Tree view populates with folders/assets +- [ ] Commands work from command palette +- [ ] Context menus appear on correct items +- [ ] Webviews display correctly in light and dark themes +- [ ] Upload functionality works +- [ ] Error messages are user-friendly + +## Creating a Package + +To create a `.vsix` file for local installation: + +```bash +npm run package +``` + +This creates `cloudinary-x.x.x.vsix` in the project root. + +### Install Locally + +1. Open VS Code +2. Go to Extensions (`Ctrl+Shift+X` / `Cmd+Shift+X`) +3. Click `...` menu → **Install from VSIX...** +4. Select the `.vsix` file + +## Code Style + +- Use TypeScript strict mode +- Follow existing patterns in the codebase +- Use `handleCloudinaryError()` for API errors +- Use the webview design system for UI components +- Keep dependencies minimal + +## Useful VS Code Commands + +When debugging the extension: + +| Command | Description | +|---------|-------------| +| `Developer: Reload Window` | Reload after code changes | +| `Developer: Toggle Developer Tools` | Open browser dev tools for webviews | +| `Cloudinary: Show Welcome` | Test the welcome screen | +| `Cloudinary: Upload` | Test the upload panel | + +## Troubleshooting + +### Extension Not Loading + +1. Check the Debug Console for errors +2. Verify `main` in package.json points to `dist/extension.js` +3. Run `npm run compile` to check for TypeScript errors + +### Tree View Empty + +1. Check credentials in config file +2. Verify network connectivity +3. Check Debug Console for API errors + +### Webview Blank + +1. Open Developer Tools (`Help → Toggle Developer Tools`) +2. Check Console tab for JavaScript errors +3. Verify Content Security Policy allows your resources + diff --git a/docs/webview-system.md b/docs/webview-system.md new file mode 100644 index 0000000..6b36580 --- /dev/null +++ b/docs/webview-system.md @@ -0,0 +1,331 @@ +# Webview System + +The extension uses a modular design system for building webview UIs. CSS and JavaScript are loaded from external files, providing better maintainability and debugging. + +## Architecture + +``` +src/webview/ +├── media/ # External files loaded at runtime +│ ├── styles/ +│ │ ├── tokens.css # Design tokens (CSS custom properties) +│ │ ├── base.css # Reset, typography, utilities +│ │ └── components.css # Component styles +│ └── scripts/ +│ ├── common.js # Shared utilities (tabs, copy, collapsibles) +│ ├── upload-widget.js # Upload-specific functionality +│ └── welcome.js # Welcome screen functionality +├── webviewUtils.ts # HTML generation and CSP helpers +├── icons.ts # Centralized SVG icons +└── utils/helpers.ts # Shared utilities (escapeHtml, etc.) +``` + +## Creating a Webview + +### Basic Structure + +```typescript +import { createWebviewDocument, getScriptUri } from "../webview/webviewUtils"; +import { escapeHtml } from "../webview/utils/helpers"; +import { assetIcons, actionIcons } from "../webview/icons"; + +function openMyPanel(context: vscode.ExtensionContext) { + const panel = vscode.window.createWebviewPanel( + "myPanelId", + "My Panel Title", + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(context.extensionUri, "src", "webview", "media"), + ], + } + ); + + // Optional: Add custom script + const myScriptUri = getScriptUri(panel.webview, context.extensionUri, "my-script.js"); + + panel.webview.html = createWebviewDocument({ + title: "My Panel", + webview: panel.webview, + extensionUri: context.extensionUri, + bodyContent: getMyContent(), + bodyClass: "layout-centered", // Optional + additionalScripts: [myScriptUri], // Optional + inlineScript: "initCommon(); initMyPanel();", // Required + }); + + // Handle messages from webview + panel.webview.onDidReceiveMessage((message) => { + // Handle message + }); +} +``` + +### Key Points + +1. **Always call `initCommon()`** in the inline script - this initializes tabs, copy buttons, and collapsibles +2. **Use `escapeHtml()`** for any dynamic content +3. **Import icons** from the centralized module +4. **Specify `localResourceRoots`** to allow loading external files + +## CSS Architecture + +### Design Tokens (`tokens.css`) + +CSS custom properties based on Cloudinary brand and VS Code theming: + +```css +:root { + /* Cloudinary Brand Colors */ + --cld-brand-blue: #3448C5; + --cld-sky-blue: #0D9AFF; + + /* Semantic Colors (VS Code with Cloudinary fallbacks) */ + --color-accent: var(--vscode-textLink-foreground, var(--cld-sky-blue)); + --color-success: var(--vscode-testing-iconPassed, #60CFB7); + --color-error: var(--vscode-testing-iconFailed, #FE5981); + + /* Surfaces */ + --color-surface: var(--vscode-editor-background); + --color-surface-elevated: var(--vscode-editorWidget-background); + --color-border: var(--vscode-editorWidget-border); + + /* Spacing */ + --space-sm: 0.5rem; + --space-md: 0.75rem; + --space-lg: 1rem; + --space-xl: 1.5rem; +} +``` + +### Base Styles (`base.css`) + +- CSS reset +- Typography defaults +- Flexbox utilities (`.flex`, `.items-center`, `.gap-md`) +- Spacing utilities (`.mt-lg`, `.mb-md`) +- Animations (`.animate-fade-in`) + +### Component Styles (`components.css`) + +Styles for all UI components: + +| Component | Classes | +|-----------|---------| +| Buttons | `.btn`, `.btn--primary`, `.btn--secondary`, `.btn--sm` | +| Cards | `.card`, `.card--elevated`, `.card__header`, `.card__body` | +| Tabs | `.tabs`, `.tabs__nav`, `.tabs__btn`, `.tabs__content` | +| Inputs | `.input`, `.select`, `.form-group` | +| Progress | `.progress-bar`, `.upload-queue` | +| Badges | `.badge`, `.badge--success`, `.meta-tags` | +| Drop Zone | `.drop-zone`, `.drop-zone--active` | +| Lightbox | `.lightbox`, `.lightbox__content` | + +## JavaScript Architecture + +### Common Script (`common.js`) + +Shared functionality loaded by all webviews: + +```javascript +// Initialize all common functionality +function initCommon() { + initVSCode(); // acquireVsCodeApi() + initTabs(); // Tab switching + initCopyButtons(); // Copy to clipboard + initCollapsibles();// Expandable sections + initLightbox(); // Image lightbox +} + +// Utility functions +function copyToClipboard(text) { ... } +function formatFileSize(bytes) { ... } +function truncateString(str, maxLength) { ... } +``` + +### View-Specific Scripts + +Each webview can have its own script: + +| Script | Purpose | Init Function | +|--------|---------|---------------| +| `upload-widget.js` | File uploads, progress, presets | `initUploadWidget(config)` | +| `welcome.js` | Welcome screen interactions | (auto-init) | + +### Initialization Pattern + +```javascript +// In inline script +initCommon(); // Always first +initUploadWidget({ + cloudName: "my-cloud", + presets: [...] +}); +``` + +## Content Security Policy + +The `createWebviewDocument` function generates a secure CSP: + +``` +default-src 'none'; +style-src ${webview.cspSource} 'unsafe-inline'; +script-src ${webview.cspSource} 'nonce-${nonce}'; +img-src ${webview.cspSource} https: data:; +font-src ${webview.cspSource}; +``` + +- External CSS/JS loaded from `webview.cspSource` +- Inline scripts require the nonce +- Images allowed from HTTPS and data URIs + +## Icons + +Use the centralized icon module: + +```typescript +import { assetIcons, actionIcons } from "../webview/icons"; + +// Asset type icons +assetIcons.image("lg") // Large image icon +assetIcons.video("md") // Medium video icon +assetIcons.file("sm") // Small file icon + +// Action icons +actionIcons.download("sm") +actionIcons.copy("md") +actionIcons.enlarge("md") +actionIcons.close("md") +``` + +Sizes: `sm` (16px), `md` (20px), `lg` (24px), `xl` (48px) + +## Utility Functions + +Import shared utilities: + +```typescript +import { + escapeHtml, // Escape HTML special characters + formatFileSize, // Format bytes (e.g., "2.4 MB") + truncateString, // Truncate with ellipsis + generateId, // Generate unique IDs +} from "../webview/utils/helpers"; +``` + +## Component Examples + +### Buttons + +```html + + + +``` + +### Cards + +```html +
+
+

Title

+
+
+ Content here +
+
+``` + +### Tabs + +```html +
+ +
+ Info content +
+
+ Metadata content +
+
+``` + +### Collapsibles + +```html +
+
+ Advanced Options +
+
+ Hidden content here +
+
+``` + +### Form Groups + +```html +
+ + +
Helper text
+
+``` + +### Info Rows + +```html +
+ File Size + 2.4 MB +
+``` + +## Webview Communication + +### Extension → Webview + +```typescript +panel.webview.postMessage({ + command: 'update', + data: { ... } +}); +``` + +### Webview → Extension + +```javascript +// In webview +const vscode = acquireVsCodeApi(); +vscode.postMessage({ + command: 'doSomething', + data: { ... } +}); + +// In extension +panel.webview.onDidReceiveMessage((message) => { + if (message.command === 'doSomething') { + // Handle + } +}); +``` + +## Adding a New Component + +1. **Add styles** to `src/webview/media/styles/components.css` +2. **Add JavaScript** (if needed) to `src/webview/media/scripts/common.js` +3. **Add TypeScript generator** (if complex) to `src/webview/components/` +4. **Export** from `src/webview/components/index.ts` + +## Debugging Webviews + +1. Open Developer Tools: `Help → Toggle Developer Tools` +2. Select the webview iframe in Elements panel +3. Check Console for JavaScript errors +4. Inspect Network tab for failed resource loads +5. Verify CSP isn't blocking resources diff --git a/esbuild.js b/esbuild.js index 5ca1c87..c207238 100644 --- a/esbuild.js +++ b/esbuild.js @@ -4,7 +4,8 @@ const production = process.argv.includes("--production"); const watch = process.argv.includes("--watch"); async function main() { - const ctx = await esbuild.context({ + // Build extension (Node.js) + const extensionCtx = await esbuild.context({ entryPoints: ["src/extension.ts"], bundle: true, format: "cjs", @@ -15,16 +16,31 @@ async function main() { outfile: "dist/extension.js", external: ["vscode"], logLevel: "warning", - plugins: [ - /* add to the end of plugins array */ - esbuildProblemMatcherPlugin, + plugins: [esbuildProblemMatcherPlugin], + }); + + // Build webview scripts (browser) + const webviewCtx = await esbuild.context({ + entryPoints: [ + "src/webview/client/preview.ts", + "src/webview/client/upload-widget.ts", + "src/webview/client/welcome.ts", ], + bundle: true, + format: "iife", + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: "browser", + outdir: "media/scripts", + logLevel: "warning", }); + if (watch) { - await ctx.watch(); + await Promise.all([extensionCtx.watch(), webviewCtx.watch()]); } else { - await ctx.rebuild(); - await ctx.dispose(); + await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]); + await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]); } } diff --git a/media/styles/base.css b/media/styles/base.css new file mode 100644 index 0000000..a706d22 --- /dev/null +++ b/media/styles/base.css @@ -0,0 +1,167 @@ +/** + * Base styles for Cloudinary VS Code extension webviews. + * Reset, typography, and layout utilities. + */ + +/* ======================================== + CSS Reset + ======================================== */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 14px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif); + font-size: var(--font-md); + line-height: 1.5; + color: var(--color-text); + background-color: var(--color-surface); + padding: var(--space-xl); +} + +/* ======================================== + Typography + ======================================== */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.3; + color: var(--color-text); +} + +h1 { font-size: var(--font-xxl); } +h2 { font-size: var(--font-xl); } +h3 { font-size: var(--font-lg); } + +p { + margin-bottom: var(--space-md); + line-height: 1.6; +} + +a { + color: var(--color-accent); + text-decoration: none; + transition: color var(--transition-normal); +} + +a:hover { + color: var(--color-accent-hover); + text-decoration: underline; +} + +code { + font-family: var(--vscode-editor-font-family, 'Consolas', 'Courier New', monospace); + font-size: 0.9em; + background-color: var(--color-surface); + padding: 0.1em 0.3em; + border-radius: var(--radius-sm); +} + +strong { + font-weight: 600; +} + +/* ======================================== + Layout Utilities + ======================================== */ +.hidden { + display: none !important; +} + +.text-center { text-align: center; } +.text-left { text-align: left; } +.text-right { text-align: right; } + +.text-muted { + color: var(--color-text-muted); +} + +/* Flexbox utilities */ +.flex { display: flex; } +.flex-col { flex-direction: column; } +.flex-wrap { flex-wrap: wrap; } +.items-center { align-items: center; } +.items-start { align-items: flex-start; } +.items-end { align-items: flex-end; } +.justify-center { justify-content: center; } +.justify-between { justify-content: space-between; } +.justify-end { justify-content: flex-end; } +.gap-xs { gap: var(--space-xs); } +.gap-sm { gap: var(--space-sm); } +.gap-md { gap: var(--space-md); } +.gap-lg { gap: var(--space-lg); } +.gap-xl { gap: var(--space-xl); } + +/* Spacing utilities */ +.mt-sm { margin-top: var(--space-sm); } +.mt-md { margin-top: var(--space-md); } +.mt-lg { margin-top: var(--space-lg); } +.mt-xl { margin-top: var(--space-xl); } +.mt-xxl { margin-top: var(--space-xxl); } + +.mb-sm { margin-bottom: var(--space-sm); } +.mb-md { margin-bottom: var(--space-md); } +.mb-lg { margin-bottom: var(--space-lg); } +.mb-xl { margin-bottom: var(--space-xl); } +.mb-xxl { margin-bottom: var(--space-xxl); } + +/* ======================================== + Animations + ======================================== */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.animate-fade-in { + animation: fadeIn var(--transition-slow) ease-out; +} + +.animate-slide-up { + animation: slideUp var(--transition-slow) ease-out; +} + +/* ======================================== + Page Layouts + ======================================== */ +.layout-centered { + display: flex; + justify-content: center; + align-items: flex-start; + min-height: 100vh; +} + +.container { + width: 100%; + max-width: 1000px; + margin: 0 auto; + padding: var(--space-xxl); +} + +.container--sm { max-width: 600px; } +.container--lg { max-width: 1200px; } diff --git a/media/styles/components.css b/media/styles/components.css new file mode 100644 index 0000000..31aa053 --- /dev/null +++ b/media/styles/components.css @@ -0,0 +1,1125 @@ +/** + * Component styles for Cloudinary VS Code extension webviews. + */ + +/* ======================================== + Buttons + ======================================== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + font-family: inherit; + font-size: var(--font-md); + font-weight: 500; + line-height: 1; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + border: none; + border-radius: var(--radius-sm); + padding: 0.6rem 1.25rem; + transition: + background-color var(--transition-normal), + color var(--transition-normal), + border-color var(--transition-normal), + opacity var(--transition-normal); +} + +.btn:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Primary button */ +.btn--primary { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.btn--primary:hover:not(:disabled) { + background-color: var(--vscode-button-hoverBackground); +} + +/* Secondary button */ +.btn--secondary { + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); +} + +.btn--secondary:hover:not(:disabled) { + background-color: var(--vscode-button-secondaryHoverBackground); +} + +/* Ghost button */ +.btn--ghost { + background-color: transparent; + color: var(--color-text); +} + +.btn--ghost:hover:not(:disabled) { + background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31)); +} + +/* Button sizes */ +.btn--sm { + padding: 0.35rem 0.75rem; + font-size: var(--font-sm); +} + +.btn--lg { + padding: 0.75rem 1.5rem; + font-size: var(--font-lg); +} + +/* Button groups */ +.btn-group { + display: flex; + flex-wrap: wrap; + gap: var(--space-md); + margin-top: var(--space-lg); +} + +.btn-group--center { justify-content: center; } +.btn-group--end { justify-content: flex-end; } + +/* ======================================== + Cards & Panels + ======================================== */ +.card { + background-color: var(--color-surface-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.card--elevated { + box-shadow: var(--shadow-lg); + border: none; +} + +.card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-lg); + border-bottom: 1px solid var(--color-border); +} + +.card__title { + margin: 0; + font-size: var(--font-xl); + font-weight: 600; +} + +.card__body { + padding: var(--space-lg); +} + +.card__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-sm); + padding: var(--space-md) var(--space-lg); + border-top: 1px solid var(--color-border); + background-color: var(--color-surface); +} + +/* Card content styles */ +.card h3 { + margin: 0 0 var(--space-lg) 0; + font-size: var(--font-xl); + font-weight: 600; +} + +.card h4 { + margin: var(--space-lg) 0 var(--space-md) 0; + font-size: var(--font-lg); + font-weight: 600; +} + +.card h4:first-child { margin-top: 0; } + +.card p { + margin: 0 0 var(--space-md) 0; + line-height: 1.6; +} + +.card p:last-child { margin-bottom: 0; } + +.card ul, +.card ol { + margin: var(--space-md) 0; + padding-left: var(--space-xl); +} + +.card li { + margin-bottom: var(--space-sm); + line-height: 1.5; +} + +.card li:last-child { margin-bottom: 0; } + +/* Panel (full-width container) */ +.panel { + background-color: var(--color-surface-elevated); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + padding: var(--space-xl); + width: 100%; +} + +.panel--sm { max-width: 400px; } +.panel--md { max-width: 600px; } +.panel--lg { max-width: 750px; } +.panel--xl { max-width: 1000px; } + +.panel__title { + margin: 0 0 var(--space-lg) 0; + font-size: var(--font-xl); + font-weight: 600; +} + +/* ======================================== + Tabs + ======================================== */ +.tabs { + width: 100%; +} + +.tabs__nav { + display: flex; + gap: 0; + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--space-lg); + overflow: visible; +} + +.tabs__btn { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + padding: 0.65rem 1.25rem; + background: none; + border: none; + font-family: inherit; + font-size: var(--font-md); + font-weight: 500; + color: var(--color-text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: + color var(--transition-normal), + border-color var(--transition-normal); + white-space: nowrap; +} + +.tabs__btn:hover { color: var(--color-text); } + +.tabs__btn:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: -2px; +} + +.tabs__btn.active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +.tabs__content { + display: none; + animation: tabFadeIn var(--transition-slow) ease-out; +} + +.tabs__content.active { display: block; } + +@keyframes tabFadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Nested tabs */ +.tabs .tabs { margin-top: var(--space-lg); } +.tabs .tabs .tabs__nav { margin-bottom: var(--space-md); } + +/* ======================================== + Form Inputs + ======================================== */ +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 200px; +} + +.form-group__label { + font-size: var(--font-sm); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.form-group__hint { + font-size: var(--font-xs); + color: var(--color-text-muted); + margin-top: var(--space-xs); +} + +.input { + width: 100%; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, var(--color-border)); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem; + font-family: inherit; + font-size: var(--font-md); + line-height: 1.4; + transition: border-color var(--transition-normal); +} + +.input:focus { + outline: none; + border-color: var(--vscode-focusBorder); +} + +.input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.input::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.select { + width: 100%; + background-color: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border, var(--color-border)); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem; + font-family: inherit; + font-size: var(--font-md); + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M2 4l4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2rem; +} + +.select:focus { + outline: none; + border-color: var(--vscode-focusBorder); +} + +.settings-row { + display: flex; + gap: var(--space-lg); + flex-wrap: wrap; +} + +.settings-row .form-group { flex: 1; } + +.setting-card { + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 0.75rem 1rem; +} + +/* ======================================== + Badges + ======================================== */ +.badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.5rem; + font-size: var(--font-xs); + font-weight: 500; + border-radius: var(--radius-sm); + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + white-space: nowrap; +} + +.badge--primary { + background-color: var(--cld-sky-blue); + color: white; +} + +.badge--pill { border-radius: var(--radius-full); } + +/* ======================================== + Info Rows + ======================================== */ +.info-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: var(--space-sm) 0; + border-bottom: 1px solid var(--color-border); + gap: var(--space-md); +} + +.info-row:last-child { border-bottom: none; } + +.info-row__label { + color: var(--color-text-muted); + font-size: var(--font-sm); + flex-shrink: 0; + min-width: 120px; +} + +.info-row__value { + font-size: var(--font-sm); + text-align: right; + word-break: break-word; + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; + justify-content: flex-end; +} + +/* URL Items */ +.url-item { + margin-bottom: var(--space-lg); +} + +.url-item:last-child { margin-bottom: 0; } + +.url-item__label { + font-size: var(--font-sm); + color: var(--color-text-muted); + margin-bottom: var(--space-xs); +} + +.url-item__value { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.url-item__link { + font-size: var(--font-sm); + word-break: break-all; + flex: 1; + min-width: 0; +} + +/* ======================================== + Progress Bars + ======================================== */ +.progress { + width: 100%; + height: 6px; + background-color: var(--vscode-progressBar-background, rgba(0, 120, 212, 0.2)); + border-radius: 3px; + overflow: hidden; +} + +.progress__bar { + height: 100%; + background-color: var(--vscode-progressBar-foreground, #0078d4); + border-radius: 3px; + transition: width var(--transition-normal) ease-out; +} + +/* Queue items */ +.queue-item { + display: flex; + align-items: center; + gap: var(--space-md); + padding: 0.6rem 0.75rem; + background: var(--color-surface); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + margin-bottom: var(--space-sm); +} + +.queue-item:last-child { margin-bottom: 0; } + +.queue-item__name { + flex-shrink: 0; + max-width: 200px; + font-size: var(--font-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.queue-item__progress { + flex: 1; + min-width: 100px; +} + +.queue-item__status { + flex-shrink: 0; + font-size: var(--font-sm); + color: var(--color-text-muted); + min-width: 80px; + text-align: right; +} + +.queue-item--complete .progress__bar { background-color: var(--color-success); } +.queue-item--complete .queue-item__status { color: var(--color-success); } +.queue-item--error .progress__bar { background-color: var(--color-error); } +.queue-item--error .queue-item__status { color: var(--color-error); } + +.upload-queue { + margin-top: var(--space-lg); +} + +.upload-queue:empty { display: none; } + +/* ======================================== + Drop Zone + ======================================== */ +.drop-zone { + border: 2px dashed var(--color-border); + border-radius: var(--radius-xl); + padding: var(--space-xxl) var(--space-xl); + text-align: center; + background-color: var(--color-surface); + transition: + border-color var(--transition-normal), + background-color var(--transition-normal); + cursor: pointer; +} + +.drop-zone:hover, +.drop-zone.drag-over { + border-color: var(--vscode-focusBorder); + background-color: rgba(0, 120, 212, 0.05); +} + +.drop-zone.drag-over { border-style: solid; } + +.drop-zone__icon { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--space-md); + color: var(--color-accent); + opacity: 0.8; +} + +.drop-zone__icon svg { + width: 48px; + height: 48px; +} + +.drop-zone__text { + margin: var(--space-xs) 0; + color: var(--color-text-muted); + font-size: var(--font-md); +} + +.drop-zone__hint { + margin: var(--space-md) 0; + font-size: var(--font-sm); + color: var(--color-text-muted); + opacity: 0.6; +} + +.drop-zone__input { display: none; } + +/* ======================================== + Asset Grid & Cards + ======================================== */ +.asset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--space-lg); +} + +.asset-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 0.6rem; + text-align: center; + transition: + border-color var(--transition-normal), + transform var(--transition-normal); +} + +.asset-card:hover { + border-color: var(--vscode-focusBorder); + transform: translateY(-2px); +} + +.asset-card__thumbnail { + position: relative; + cursor: pointer; +} + +.asset-card__image { + width: 130px; + height: 100px; + object-fit: cover; + border-radius: var(--radius-md); + background: var(--color-surface-elevated); +} + +.asset-card__icon { + width: 130px; + height: 100px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-surface-elevated); + border-radius: var(--radius-md); + color: var(--color-text-muted); +} + +.asset-card__folder { + font-size: var(--font-xs); + color: var(--color-text-muted); + margin: var(--space-xs) 0; +} + +.asset-card__id { + font-size: var(--font-xs); + color: var(--color-text-muted); + margin: var(--space-xs) 0; + word-break: break-all; + max-height: 2.4em; + overflow: hidden; +} + +.asset-card__actions { + display: flex; + gap: var(--space-xs); + justify-content: center; + flex-wrap: wrap; +} + +.uploaded-assets { + margin-top: var(--space-xl); + padding-top: var(--space-xl); + border-top: 1px solid var(--color-border); +} + +.uploaded-assets.hidden { display: none; } + +.uploaded-assets__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-lg); +} + +.uploaded-assets__title { + margin: 0; + font-size: var(--font-lg); + font-weight: 600; +} + +/* ======================================== + Lightbox & Modal + ======================================== */ +.lightbox { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + z-index: 1000; + justify-content: center; + align-items: center; + padding: var(--space-xxl); +} + +.lightbox.active { display: flex; } + +.lightbox__content { + max-width: 95%; + max-height: 95%; + object-fit: contain; + border-radius: var(--radius-lg); +} + +.lightbox__close { + position: absolute; + top: var(--space-lg); + right: var(--space-lg); + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: var(--radius-full); + width: 40px; + height: 40px; + cursor: pointer; + color: white; + font-size: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + transition: background-color var(--transition-normal); +} + +.lightbox__close:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* ======================================== + Collapsible + ======================================== */ +.collapsible { + margin-bottom: var(--space-lg); +} + +.collapsible__header { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) 0; + cursor: pointer; + user-select: none; + color: var(--color-text-muted); + font-size: var(--font-sm); + transition: color var(--transition-normal); +} + +.collapsible__header:hover { color: var(--color-text); } + +.collapsible__header::before { + content: '▶'; + font-size: 0.6rem; + transition: transform var(--transition-normal); +} + +.collapsible__header.expanded::before { + transform: rotate(90deg); +} + +.collapsible__content { + display: none; + padding: var(--space-md) 0; +} + +.collapsible__content.visible { display: block; } + +/* ======================================== + Layout Components + ======================================== */ + +/* Asset header */ +.asset-header { + display: flex; + align-items: center; + gap: var(--space-md); + margin-bottom: var(--space-lg); + padding-bottom: var(--space-md); + border-bottom: 1px solid var(--color-border); +} + +.asset-header__icon { + display: flex; + align-items: center; + justify-content: center; + color: var(--color-accent); + flex-shrink: 0; +} + +.asset-header__icon svg { + width: 28px; + height: 28px; +} + +.asset-header__content { + flex: 1; + min-width: 0; +} + +.asset-header__title { + margin: 0; + font-size: var(--font-lg); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.asset-header__subtitle { + font-size: var(--font-sm); + color: var(--color-text-muted); + margin-top: 0.2rem; + display: flex; + align-items: center; + gap: var(--space-sm); +} + +/* Hero section */ +.hero { + text-align: center; + margin-bottom: var(--space-xxl); + padding: var(--space-xxl); + background: linear-gradient(135deg, var(--cld-brand-blue) 0%, var(--cld-sky-blue) 100%); + border-radius: var(--radius-xxl); + color: white; +} + +.hero__title { + margin: 0; + font-size: 2.5rem; + font-weight: 700; + color: white; +} + +.hero__subtitle { + margin: var(--space-sm) 0 0 0; + font-size: 1.1rem; + opacity: 0.9; + color: white; +} + +/* Status card */ +.status-card { + background-color: var(--color-surface-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-xl); + margin-bottom: var(--space-xxl); + display: flex; + align-items: center; + gap: var(--space-lg); +} + +.status-card__icon { + font-size: 1.5rem; + width: 2.5rem; + height: 2.5rem; + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + color: white; + flex-shrink: 0; +} + +.status-card__icon--success { background-color: var(--color-success); } +.status-card__icon--warning { background-color: var(--cld-pink); } + +.status-card__content { flex: 1; } + +.status-card__title { + font-weight: 600; + margin-bottom: var(--space-xs); +} + +.status-card__text { + margin: 0; + color: var(--color-text-muted); +} + +/* Step list */ +.step-list { + display: flex; + flex-direction: column; + gap: var(--space-xl); +} + +.step { + display: flex; + align-items: flex-start; + gap: var(--space-lg); +} + +.step__number { + background-color: var(--cld-brand-blue); + color: white; + border-radius: var(--radius-full); + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + flex-shrink: 0; +} + +.step__content { flex: 1; } + +.step__title { + margin: 0 0 var(--space-sm) 0; + font-size: var(--font-lg); + font-weight: 600; +} + +.step__description { + margin: 0 0 var(--space-sm) 0; + color: var(--color-text-muted); +} + +/* Feature grid */ +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-xl); +} + +.feature-item { + background-color: var(--color-surface-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-xl); + text-align: center; + transition: transform var(--transition-normal); +} + +.feature-item:hover { transform: translateY(-2px); } + +.feature-item__title { + font-size: var(--font-xl); + font-weight: 600; + margin-bottom: var(--space-sm); +} + +.feature-item__description { + color: var(--color-text-muted); + font-size: var(--font-md); +} + +/* Highlight box */ +.highlight-box { + background: linear-gradient(135deg, var(--cld-brand-blue) 0%, var(--cld-purple) 100%); + color: white; + padding: var(--space-xxl); + border-radius: var(--radius-xxl); + margin: var(--space-xxl) 0; + text-align: center; +} + +.highlight-box__title { + margin: 0 0 var(--space-lg) 0; + font-size: 1.5rem; + color: white; +} + +.highlight-box__text { + margin: 0 0 var(--space-xl) 0; + opacity: 0.9; + color: white; +} + +/* Info/Warning boxes */ +.info-box { + background-color: rgba(72, 196, 216, 0.1); + border: 1px solid var(--cld-turquoise); + border-radius: var(--radius-md); + padding: var(--space-lg); + margin: var(--space-lg) 0; +} + +.info-box strong { color: var(--cld-turquoise); } + +/* Code block */ +.code-block { + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-lg); + margin: var(--space-lg) 0; + font-family: var(--vscode-editor-font-family, monospace); + font-size: var(--font-sm); + position: relative; + overflow-x: auto; +} + +.code-block pre { + margin: 0; + white-space: pre-wrap; +} + +.code-block__copy { + position: absolute; + top: var(--space-sm); + right: var(--space-sm); +} + +/* Grid layouts */ +.grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-xl); +} + +@media (max-width: 768px) { + .grid-2 { grid-template-columns: 1fr; } +} + +/* Preview container */ +.preview-container { + position: relative; + display: inline-block; + width: 100%; + margin-bottom: var(--space-lg); + background: var(--color-surface); + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--color-border); +} + +.preview-container__media { + display: block; + max-width: 100%; + max-height: 250px; + width: auto; + margin: 0 auto; + border-radius: var(--radius-md); +} + +.preview-container__enlarge { + position: absolute; + top: var(--space-sm); + right: var(--space-sm); + background: rgba(0, 0, 0, 0.7); + border: none; + border-radius: var(--radius-md); + padding: var(--space-sm); + cursor: pointer; + color: white; + opacity: 0; + transition: opacity var(--transition-normal), background var(--transition-normal); + display: flex; + align-items: center; + justify-content: center; +} + +.preview-container:hover .preview-container__enlarge { opacity: 1; } +.preview-container__enlarge:hover { background: rgba(0, 0, 0, 0.9); } + +/* Raw file preview */ +.raw-file-preview { + text-align: center; + padding: var(--space-xxl); + background: var(--color-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + margin-bottom: var(--space-lg); +} + +.raw-file-preview__icon { + margin-bottom: var(--space-md); + color: var(--color-text-muted); +} + +.raw-file-preview__name { + font-size: var(--font-md); + color: var(--color-text-muted); + margin: var(--space-sm) 0; + word-break: break-all; +} + +/* Preset details */ +.preset-toggle { + display: flex; + justify-content: space-between; + align-items: center; +} + +.preset-toggle__btn { + background: none; + border: none; + color: var(--color-accent); + cursor: pointer; + padding: 0; + font-size: var(--font-xs); + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.preset-toggle__btn::before { + content: '▶'; + font-size: 0.55rem; + transition: transform var(--transition-normal); +} + +.preset-toggle__btn.expanded::before { transform: rotate(90deg); } + +.preset-details { + margin-top: var(--space-sm); + padding: var(--space-sm); + background-color: var(--color-surface); + border-radius: var(--radius-sm); + font-size: var(--font-xs); + font-family: var(--vscode-editor-font-family, monospace); + white-space: pre-wrap; + max-height: 150px; + overflow-y: auto; + border: 1px solid var(--color-border); + display: none; + color: var(--color-text-muted); +} + +.preset-details.visible { display: block; } + +/* URL input group */ +.url-input-group { + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-md); +} + +.url-input-group .input { flex: 1; } + +/* Metadata section */ +.meta-section { + margin-bottom: var(--space-lg); +} + +.meta-section:last-child { margin-bottom: 0; } + +.meta-section__title { + font-size: var(--font-sm); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: var(--space-sm); +} + +.meta-section__empty { + color: var(--color-text-muted); + font-size: var(--font-sm); + font-style: italic; +} + +/* Links */ +.link { + color: var(--color-accent); + cursor: pointer; + text-decoration: none; + transition: color var(--transition-normal); +} + +.link:hover { text-decoration: underline; } + +/* Tags display */ +.meta-tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); +} diff --git a/media/styles/tokens.css b/media/styles/tokens.css new file mode 100644 index 0000000..7e2ac08 --- /dev/null +++ b/media/styles/tokens.css @@ -0,0 +1,90 @@ +/** + * Design tokens for the Cloudinary VS Code extension webviews. + * Based on Cloudinary Brand Guidelines: https://brand-guidelines.cloudinary.com/ + */ + +:root { + /* ======================================== + Cloudinary Brand Colors + ======================================== */ + --cld-brand-blue: #3448C5; + --cld-sky-blue: #0D9AFF; + --cld-grey: #E3E9EF; + --cld-aegean: #23436A; + --cld-cetacean: #1B295D; + + /* Accent colors */ + --cld-turquoise: #48C4D8; + --cld-pink: #FE5981; + --cld-green: #D5FDA1; + --cld-teal: #60CFB7; + --cld-purple: #A15EE4; + + /* ======================================== + Semantic Color Mappings + Maps VS Code variables with Cloudinary fallbacks + ======================================== */ + + /* Primary accent - used for links, active states */ + --color-accent: var(--vscode-textLink-foreground, var(--cld-sky-blue)); + --color-accent-hover: var(--vscode-textLink-activeForeground, var(--cld-sky-blue)); + + /* Status colors */ + --color-success: var(--vscode-testing-iconPassed, var(--cld-teal)); + --color-error: var(--vscode-testing-iconFailed, var(--cld-pink)); + --color-warning: var(--cld-pink); + --color-info: var(--cld-turquoise); + + /* Surface colors */ + --color-surface: var(--vscode-editor-background); + --color-surface-elevated: var(--vscode-editorWidget-background); + --color-border: var(--vscode-editorWidget-border); + + /* Text colors */ + --color-text: var(--vscode-editor-foreground); + --color-text-muted: var(--vscode-descriptionForeground); + + /* ======================================== + Spacing Scale + ======================================== */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 0.75rem; + --space-lg: 1rem; + --space-xl: 1.5rem; + --space-xxl: 2rem; + + /* ======================================== + Border Radius + ======================================== */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 10px; + --radius-xxl: 12px; + --radius-full: 50%; + + /* ======================================== + Font Sizes + ======================================== */ + --font-xs: 0.65rem; + --font-sm: 0.75rem; + --font-md: 0.85rem; + --font-lg: 1rem; + --font-xl: 1.15rem; + --font-xxl: 1.25rem; + + /* ======================================== + Transitions + ======================================== */ + --transition-fast: 0.1s; + --transition-normal: 0.15s; + --transition-slow: 0.2s; + + /* ======================================== + Shadows + ======================================== */ + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.15); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 4px 20px rgba(0, 0, 0, 0.25); +} diff --git a/src/commands/previewAsset.ts b/src/commands/previewAsset.ts index 68f7ccd..b80687b 100644 --- a/src/commands/previewAsset.ts +++ b/src/commands/previewAsset.ts @@ -1,713 +1,336 @@ import * as vscode from "vscode"; +import { createWebviewDocument, getScriptUri } from "../webview/webviewUtils"; +import { escapeHtml, formatFileSize } from "../webview/utils/helpers"; +import { assetIcons, actionIcons } from "../webview/icons"; type AssetData = { - public_id: string, - displayType: "image" | "video" | string, - secure_url: string, - optimized_url: string, - bytes: number, - width: number, - height: number, - filename: string, - format?: string, - resource_type?: string + public_id: string; + displayType: "image" | "video" | string; + secure_url: string; + optimized_url: string; + bytes: number; + width: number; + height: number; + filename: string; + format?: string; + resource_type?: string; + tags?: string[]; + context?: Record; + metadata?: Record; }; /** * Map of open preview panels by public_id. - * Prevents opening multiple panels for the same asset. */ const openPanels: Map = new Map(); +/** + * Get icon for asset type using centralized icons. + */ +function getAssetIcon(type: string): string { + switch (type) { + case "image": + return assetIcons.image("lg"); + case "video": + return assetIcons.video("lg"); + default: + return assetIcons.file("lg"); + } +} + function registerPreview(context: vscode.ExtensionContext) { context.subscriptions.push( - vscode.commands.registerCommand("cloudinary.openAsset", (asset: AssetData) => { - const publicId = asset.public_id; - - // Check if panel for this asset already exists - const existingPanel = openPanels.get(publicId); - if (existingPanel) { - // Reveal the existing panel - existingPanel.reveal(vscode.ViewColumn.One); - return; + vscode.commands.registerCommand( + "cloudinary.openAsset", + (asset: AssetData) => { + const publicId = asset.public_id; + + // Check if panel for this asset already exists + const existingPanel = openPanels.get(publicId); + if (existingPanel) { + existingPanel.reveal(vscode.ViewColumn.One); + return; + } + + // Get short display name for tab + const shortName = asset.public_id.includes("/") + ? asset.public_id.split("/").pop() + : asset.public_id; + + // Create new panel + const panel = vscode.window.createWebviewPanel( + "cloudinaryAssetPreview", + shortName || asset.public_id, + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(context.extensionUri, "media"), + ], + } + ); + + // Set the panel icon based on asset type + const iconFile = + asset.displayType === "image" + ? "icon-image.svg" + : asset.displayType === "video" + ? "icon-video.svg" + : "icon-file.svg"; + panel.iconPath = vscode.Uri.joinPath( + context.extensionUri, + "resources", + iconFile + ); + + // Track this panel + openPanels.set(publicId, panel); + + // Remove from tracking when disposed + panel.onDidDispose(() => { + openPanels.delete(publicId); + }); + + // Get the preview script + const previewScriptUri = getScriptUri( + panel.webview, + context.extensionUri, + "preview.js" + ); + + // Set the HTML content + panel.webview.html = createWebviewDocument({ + title: asset.public_id, + webview: panel.webview, + extensionUri: context.extensionUri, + bodyContent: getPreviewContent(asset), + bodyClass: "layout-centered", + additionalScripts: [previewScriptUri], + }); + + // Handle messages from webview + panel.webview.onDidReceiveMessage(async (message) => { + if (message.command === "copyToClipboard" && message.text) { + await vscode.env.clipboard.writeText(message.text); + } + }); } + ) + ); +} - // Get short display name for tab - const shortName = asset.public_id.includes('/') - ? asset.public_id.split('/').pop() - : asset.public_id; - - // Create new panel - const panel = vscode.window.createWebviewPanel( - "cloudinaryAssetPreview", - shortName || asset.public_id, - vscode.ViewColumn.One, - { enableScripts: true } - ); - - // Set the panel icon based on asset type - const iconFile = asset.displayType === 'image' - ? 'icon-image.svg' - : asset.displayType === 'video' - ? 'icon-video.svg' - : 'icon-file.svg'; - panel.iconPath = vscode.Uri.joinPath(context.extensionUri, "resources", iconFile); - - // Track this panel - openPanels.set(publicId, panel); - - // Remove from tracking when disposed - panel.onDidDispose(() => { - openPanels.delete(publicId); - }); - - // Format file size - const formatSize = (bytes: number) => { - if (bytes === 0) { return '0 B'; } - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; - }; - - // Get display name (last part of public_id) - const displayName = asset.public_id.includes('/') - ? asset.public_id.split('/').pop() - : asset.public_id; - - // SVG icons - const icons = { - image: ` - - `, - video: ` - - `, - file: ` - - `, - download: ` - - ` - }; - - // Determine asset type icon - const typeIcon = asset.displayType === 'image' ? icons.image : asset.displayType === 'video' ? icons.video : icons.file; - - let previewHtml = ""; - let hasEnlarge = false; - - if (asset.displayType === "image") { - hasEnlarge = true; - previewHtml = ` -
- ${asset.public_id} - -
- `; - } else if (asset.displayType === "video") { - hasEnlarge = true; - previewHtml = ` -
- - -
- `; - } else { - previewHtml = ` -
-
- - - -
-

${displayName}

- - - - - Download File - -
- `; - } - - panel.webview.html = ` - - - - - - ${asset.public_id} - - - -
- -
- ${typeIcon} -
-

${displayName}

-
- ${(asset.format || asset.displayType || 'unknown').toUpperCase()} - ${asset.width && asset.height ? `${asset.width} × ${asset.height}` : ''} - ${asset.bytes ? ` • ${formatSize(asset.bytes)}` : ''} -
+/** + * Generate the body content for the asset preview panel. + */ +function getPreviewContent(asset: AssetData): string { + const displayName = asset.public_id.includes("/") + ? asset.public_id.split("/").pop() + : asset.public_id; + + const typeIcon = getAssetTypeIcon(asset.displayType); + const { html: previewHtml, hasEnlarge } = buildPreviewHtml(asset); + const tagsHtml = buildTagsHtml(asset.tags); + const contextHtml = buildMetadataHtml(asset.context, "No context metadata"); + const metadataHtml = buildMetadataHtml(asset.metadata, "No structured metadata"); + const lightboxHtml = hasEnlarge ? buildLightboxHtml(asset) : ""; + + return ` +
+
+ +
+ ${typeIcon} +
+

${escapeHtml(displayName || "")}

+
+ ${escapeHtml((asset.format || asset.displayType || "unknown").toUpperCase())} + ${asset.width && asset.height ? `${asset.width} × ${asset.height}` : ""} + ${asset.bytes ? ` • ${formatFileSize(asset.bytes)}` : ""}
+
- - ${previewHtml} - - -