diff --git a/.gitignore b/.gitignore index 5dec777..2eda10f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ node_modules/ # Internal development files internal/ docs/superpowers/ +agents.md +.agents/ # Logs *.log diff --git a/README.md b/README.md index 5f1ed41..0fd4674 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Easily explore, search, preview, and upload Cloudinary assets directly inside Vi ## Features -- **Asset Explorer** – View Cloudinary folders and assets in a VS Code Tree View +- **Asset Explorer** – View Cloudinary folders and assets in the sidebar media library - **Search & Filter** – Quickly locate assets by public ID or type - **Optimized Preview** – Preview images/videos with Cloudinary transformations applied (`f_auto`, `q_auto`) - **Right-click Actions** – Copy Public ID or URL instantly @@ -75,7 +75,7 @@ Once a valid configuration has been added, the active environment will be shown ### Upload -- Click **Upload** from the title bar to open the upload panel, or click **Upload here** on a folder entry to open with that folder pre-selected. +- Click **Upload** from the library toolbar to open the upload panel, or click **Upload here** on a folder entry to open with that folder pre-selected. - Alternatively, run `Cloudinary: Upload` from the command palette. **Upload Panel Features:** @@ -94,8 +94,8 @@ Once a valid configuration has been added, the active environment will be shown ![Uploading assets](https://res.cloudinary.com/demo/video/upload/w_1200/f_auto:animated/q_auto/e_accelerate:100/e_loop/docs/vscode-extension-vid3) ### Filter or Search -- Click "Filter" in the title bar or run `Cloudinary: Filter` to narrow assets by type -- Click "Search" in the title bar or run `Cloudinary: Search` to search by public ID +- Use the always-visible filter controls in the media library to narrow assets by type or change sort order. +- Use the always-visible search field in the media library to search by public ID. ![Filtering and searching](https://res.cloudinary.com/demo/video/upload/w_1200/f_auto:animated/q_auto/e_accelerate:50/e_loop/docs/vscode-extension-vid2) @@ -113,16 +113,17 @@ Once a valid configuration has been added, the active environment will be shown - **Metadata** – View tags, context metadata, and structured metadata - **URLs** – Copy original or optimized URLs with one click - **Type Icons** – Tab icons indicate asset type (image, video, or file) +- **Authenticated Assets** – Authenticated delivery assets are marked with a lock and use a signed original URL for preview/copy actions -### Refresh Tree -- Click "Refresh" to reload the tree +### Refresh Library +- Click **Refresh** to reload the media library --- ## Known Limitations - Asset filtering is limited to basic types (image, video, raw) -- No options to control number of items returned in tree or root folder -- Folder dropdown in upload panel only shows folders that have been browsed in the tree view +- No user-facing option to control library page size or prefetch cap +- Folder dropdown discovery is capped to a bounded folder depth for responsiveness --- diff --git a/docs/adding-features.md b/docs/adding-features.md index 5439ff0..69f0200 100644 --- a/docs/adding-features.md +++ b/docs/adding-features.md @@ -1,26 +1,32 @@ # Adding Features -This guide explains how to add new functionality to the Cloudinary VS Code Extension. +This guide covers the current extension shape after the sidebar moved to webview-based homescreen and library views. ## Adding a New Command -### 1. Create the Command File +Commands still live in `src/commands/`, but they now work from shared services and webview providers rather than a sidebar tree state object. -Create a new file in `src/commands/`: +### 1. Create the Command File ```typescript // src/commands/myNewCommand.ts import * as vscode from "vscode"; -import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { CloudinaryService } from "../cloudinary/cloudinaryService"; function registerMyNewCommand( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider + cloudinaryService: CloudinaryService ) { context.subscriptions.push( vscode.commands.registerCommand("cloudinary.myNewCommand", async () => { - // Implementation here - vscode.window.showInformationMessage("Command executed!"); + if (!cloudinaryService.cloudName) { + vscode.window.showWarningMessage("Cloudinary is not configured."); + return; + } + + vscode.window.showInformationMessage( + `Running against ${cloudinaryService.cloudName}` + ); }) ); } @@ -28,23 +34,29 @@ function registerMyNewCommand( export default registerMyNewCommand; ``` +If the command needs to update the library UI, accept `LibraryWebviewViewProvider` and call methods such as `refresh()`, `setSearch()`, or `applyView(...)`. + ### 2. Register the Command -Add to `src/commands/registerCommands.ts`: +Add it in `src/commands/registerCommands.ts`: ```typescript import registerMyNewCommand from "./myNewCommand"; function registerAllCommands( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider + cloudinaryService: CloudinaryService, + environmentTarget: Parameters[1], + statusBar: vscode.StatusBarItem, + homescreenProvider: HomescreenViewProvider, + libraryWebview?: LibraryWebviewViewProvider ) { - // ... existing registrations - registerMyNewCommand(context, provider); + // ...existing registrations + registerMyNewCommand(context, cloudinaryService); } ``` -### 3. Add to Package.json +### 3. Add It to `package.json` ```json { @@ -53,311 +65,155 @@ function registerAllCommands( { "command": "cloudinary.myNewCommand", "title": "My New Command", - "category": "Cloudinary", - "icon": "$(symbol-misc)" + "category": "Cloudinary" } ] } } ``` -### 4. Add Menu Placement (Optional) +The library toolbar and context actions are rendered inside the webview, so most new UI affordances do not need `contributes.menus`. -```json -{ - "contributes": { - "menus": { - "view/title": [ - { - "command": "cloudinary.myNewCommand", - "when": "view == cloudinaryMediaLibrary", - "group": "navigation" - } - ], - "view/item/context": [ - { - "command": "cloudinary.myNewCommand", - "when": "viewItem == asset", - "group": "inline" - } - ] - } - } -} -``` +## Extending the Library Webview -## Adding a New Tree Item Type +The media library is implemented by `src/webview/libraryView.ts` plus the client bundle in `src/webview/client/library.ts`. -### 1. Add the Type +### Host-side changes -In `src/tree/cloudinaryItem.ts`: +Use `LibraryWebviewViewProvider` when you need to: -```typescript -export type CloudinaryItemType = - | 'asset' - | 'folder' - | 'loadMore' - | 'myNewType'; // Add your type -``` +- send new data into the webview +- respond to `postMessage(...)` events from the client +- persist small UI preferences in `context.globalState` -### 2. Handle in Constructor +Host messages are handled in `handleMessage(...)`. Outbound posts use the private `post(...)` helper, which wraps `webview.postMessage(...)` in a try/catch so late callbacks (prefetches, env-change broadcasts) do not throw `Webview is disposed` after a view collapses. Add new helpers via the same wrapper rather than calling `webview.postMessage` directly. -```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], - }; -} -``` +When something on the host changes that the client should reflect immediately (env switch, refresh), prefer the existing routes — `envChanged()`, `refresh()`, `setSearch()`, `applyView(...)` — instead of posting raw messages. -### 3. Create Items in Provider +### Client-side changes -In `src/tree/treeDataProvider.ts`: +Add interactive behavior in `src/webview/client/`: -```typescript -const myItem = new CloudinaryItem( - 'Item Label', - vscode.TreeItemCollapsibleState.None, - 'myNewType', - { customData: 'value' }, - this.cloudName!, - this.dynamicFolders -); -items.push(myItem); -``` +- `library.ts` — main row rendering, selection, keyboard nav, message dispatch +- `libraryVirtualList.ts` — windowed render math (row height fixed at 22px) +- `libraryRowSplice.ts` — depth-aware splice helper for streaming nested folder appends +- `libraryIcons.ts` — duplicated SVG icon set used at row-render time (the client cannot import `src/webview/icons.ts`; keep these two in sync when adding a glyph used in both surfaces) +- `libraryMenu.ts` — context menu +- `libraryHoverPreview.ts` — delayed thumbnail hover card with metadata strip -### 4. Add Context Menu (Optional) +These files are bundled by `esbuild.js` into `media/scripts/`. -```json -{ - "contributes": { - "menus": { - "view/item/context": [ - { - "command": "cloudinary.myCommand", - "when": "viewItem == myNewType" - } - ] - } - } -} -``` +### Library UI conventions -## Adding a New Webview +- **Row height** is fixed at 22px (`--lib-row-height`). Do not introduce variable row heights — virtualization assumes a constant. +- **Icon slots** are 18×22 flexbox-centered. New row kinds should follow the same pattern: `` so vertical alignment matches existing rows. +- **Brand accents** come from `tokens.css` — use `--lib-accent` (sky blue) and the `--lib-accent-soft/-strong` derivatives. Avoid hardcoded colors. +- **Toolbar groups** are HTML siblings with `gap` for spacing instead of vertical hairline dividers; the utility group uses `margin-left: auto` to push to the right edge. +- **Search and filters** stay visible in the library header. Do not add toolbar toggles for core browse controls unless the layout changes again. -### 1. Create the Command File +## Adding a New Webview Panel or View + +Use the shared webview helpers in `src/webview/webviewUtils.ts`: ```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; - }); - }) +import { + createWebviewDocument, + getScriptUri, + getStyleUri, +} from "../webview/webviewUtils"; + +function openMyPanel(context: vscode.ExtensionContext) { + const panel = vscode.window.createWebviewPanel( + "cloudinaryMyPanel", + "My Panel", + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, "media")], + } ); -} -function getContent(): string { - return ` -
-
-
-

My Webview

-

Content here

- -
-
-
- `; + const scriptUri = getScriptUri(panel.webview, context.extensionUri, "my-panel.js"); + const styleUri = getStyleUri(panel.webview, context.extensionUri, "my-panel.css"); + + panel.webview.html = createWebviewDocument({ + title: "My Panel", + webview: panel.webview, + extensionUri: context.extensionUri, + bodyContent: `
`, + additionalStyles: [styleUri], + additionalScripts: [scriptUri], + }); } - -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). +Keep styling on VS Code theme tokens such as `--vscode-editor-foreground`. ## Adding Configuration Options -### 1. Update Configuration Interface +Configuration still comes from Cloudinary environment files, not VS Code settings. -In `src/config/configUtils.ts`: +### 1. Update the Config Type -```typescript -export interface CloudinaryEnvironment { - apiKey: string; - apiSecret: string; - uploadPreset?: string; - myNewOption?: string; // Add your option -} -``` +Add the field to `CloudinaryEnvironment` in `src/config/configUtils.ts`. -### 2. Use in Code +### 2. Thread It Through Activation -```typescript -const myOption = provider.getConfig().myNewOption || 'default'; -``` +If the option affects runtime behavior, read it during activation or environment switching and write it onto `CloudinaryService` or the specific provider/panel that needs it. -### 3. Document the Option +### 3. Document It -Update `docs/configuration.md` with the new option. +Update the relevant docs in `docs/`. ## Common Patterns ### Error Handling ```typescript -import { handleCloudinaryError } from '../utils/cloudinaryErrorHandler'; +import { handleCloudinaryError } from "../utils/cloudinaryErrorHandler"; try { - const result = await cloudinary.api.someMethod(); + await cloudinaryService.fetchChildren(""); } catch (err: any) { - handleCloudinaryError('Operation failed', err); + handleCloudinaryError("Failed to load assets", 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' + placeHolder: "Enter search term", + prompt: "Search assets by public ID", }); -if (!query) return; // User cancelled - -// Quick pick -const selected = await vscode.window.showQuickPick( - ['Option 1', 'Option 2', 'Option 3'], - { placeHolder: 'Select an option' } -); +if (!query) { + return; +} ``` -### Progress Indicator +### Refreshing the Sidebar ```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 }); - } -); +await libraryWebview?.refresh(); +await homescreenProvider.refresh(); ``` -### Refresh Tree View - -```typescript -// After modifying data -provider.refresh(); // Fires _onDidChangeTreeData -``` +Use the narrowest refresh path you need. Do not recreate deprecated tree-provider refresh flows. ## 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 +1. Run `npm run check-types`. +2. Run `npm run compile`. +3. Run `npm run lint` for command and extension changes. +4. Run `npm run compile-tests` when you add or update tests. +5. Launch the Extension Development Host with `F5` for manual verification. ## 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 +1. Keep command registration patterns consistent with `src/commands/registerCommands.ts`. +2. Use `CloudinaryService` for Cloudinary state and API access. +3. Keep webview host logic in `src/webview/*.ts` and browser-side logic in `src/webview/client/*.ts`. +4. Use `handleCloudinaryError()` for Cloudinary API failures. +5. Prefer narrowing documentation claims over describing behavior that is not implemented. diff --git a/docs/architecture.md b/docs/architecture.md index 015952a..0d198b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,30 +1,30 @@ # Architecture -The extension is built on VS Code's extension API and integrates with Cloudinary's Admin and Upload APIs. +The extension is a VS Code sidebar integration built around a shared `CloudinaryService` plus webview-based UI surfaces. -### Core Components +## Core Components -``` +```text ┌─────────────────────────────────────────────────────────────┐ -│ VS Code Extension │ +│ VS Code Extension │ ├─────────────────────────────────────────────────────────────┤ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Tree View │ │ Webviews │ │ Commands │ │ -│ │ (Sidebar) │ │ (Panels) │ │ (Actions) │ │ +│ │ Homescreen │ │ Library │ │ Commands │ │ +│ │ Webview │ │ Webview │ │ & Panels │ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ │ │ │ │ └────────────┬────┴────────────────┘ │ │ │ │ │ ┌────────────▼────────────┐ │ -│ │ CloudinaryTreeProvider │ │ -│ │ (State & API Layer) │ │ +│ │ CloudinaryService │ │ +│ │ State + API boundary │ │ │ └────────────┬────────────┘ │ │ │ │ ├──────────────────────┼──────────────────────────────────────┤ │ │ │ │ ┌────────────▼────────────┐ │ -│ │ Cloudinary SDK │ │ -│ │ (Node.js v2) │ │ +│ │ Cloudinary SDK Adapter │ │ +│ │ wraps Node SDK usage │ │ │ └────────────┬────────────┘ │ │ │ │ └──────────────────────┼──────────────────────────────────────┘ @@ -37,17 +37,13 @@ The extension is built on VS Code's extension API and integrates with Cloudinary └───────────────────────┘ ``` -### 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 +## Key Design Decisions -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` +1. Configuration lives in Cloudinary environment files, not VS Code settings. +2. `CloudinaryService` owns runtime Cloudinary state such as credentials, folder mode, and upload presets. +3. The sidebar is webview-driven: `cloudinaryHomescreen` and `cloudinaryMediaLibrary` are both webview views. +4. Webview host code lives in `src/webview/*.ts`; browser-side behavior lives in `src/webview/client/*.ts`. +5. The Cloudinary SDK is wrapped behind `createCloudinarySdkAdapter()` so service logic stays testable. ## Technology Stack @@ -56,85 +52,89 @@ The extension is built on VS Code's extension API and integrates with Cloudinary | Language | TypeScript (strict mode) | | Build | esbuild | | Runtime | VS Code Extension Host (Node.js) | -| API Client | Cloudinary Node.js SDK v2.x | +| API Client | Cloudinary Node.js SDK v2.x via adapter | | Testing | Mocha + VS Code Test Electron | | Linting | ESLint | ## Data Flow -### Configuration Loading +### Activation and Configuration -``` +```text 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) +2. Load environments from .cloudinary/environments.json or ~/.cloudinary/environments.json +3. Validate credentials and configure the Cloudinary SDK +4. Detect folder mode and cache it in global state +5. Create shared CloudinaryService +6. Register homescreen and library webview providers +7. Register commands against the shared service/providers ``` -### Tree View Population +### Library Loading -``` -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 +```text +1. Library webview posts "ready" +2. LibraryWebviewViewProvider reads current view state +3. Provider calls CloudinaryService for folders/assets/search results +4. Provider posts root/search/folder messages to the client +5. Client renders the virtualized tree/list UI ``` +The library renders a compact header (brand strip, action toolbar, filter controls, and search field) above the virtualized list: + +- **Brand strip** — Cloudinary logo, wordmark, and active cloud name pill. The pill is populated from the `envChanged` message posted on `ready` and on every environment switch. +- **Action toolbar** — icon-button groups for navigation, refresh, upload, and configuration. Search and filtering are not toolbar toggles; those controls remain visible below the toolbar. +- **Search and filter controls** — resource type, sort order, and search input stay visible so the core browse controls are always one interaction away. +- **Row grid** — every row is 22px tall with three uniform 18px slots (twistie · icon · spacer) so folders, assets, loading, and clear-search rows share an exact rhythm. Row content is flex-centered so glyphs and labels sit in the middle of the selection band. +- **Folder iconography** — separate closed (stroked outline) and open (filled, two-tone) glyphs so expand state reads from the icon as well as the chevron rotation. +- **Authenticated delivery** — assets with `type: authenticated` are marked with a lock in the list and preview. The service signs the original delivery URL and reuses it for preview fields instead of generating dynamic optimization/thumbnail transformations. +- **Hover preview** — narrow card with a brand-gradient hairline, 176×176 thumbnail, resource-type chip, and truncated caption. Smart left/right placement based on viewport edges. +- **Welcome empty state** — gradient-edged card shown when no credentials are configured; CTA button posts `runToolbar` `openGlobalConfig`. +- **Reduced motion** — all animations and transitions are disabled under `@media (prefers-reduced-motion: reduce)`. + ### Webview Communication -``` -Extension Webview - │ │ - │ panel.webview.html = ... │ - │──────────────────────────────────>│ - │ │ - │ vscode.postMessage({...}) │ - │<──────────────────────────────────│ - │ │ - │ panel.webview.postMessage({...})│ - │──────────────────────────────────>│ - │ │ +```text +Extension host Webview client + │ │ + │ webview.html = ... │ + │─────────────────────────────────>│ + │ │ + │ postMessage({ command: ... }) │ + │─────────────────────────────────>│ + │ │ + │ onDidReceiveMessage(...) │ + │<─────────────────────────────────│ ``` +The homescreen drives navigation and search entry points. The library handles browsing, filtering, sorting, selection, context actions, and scroll state. + +Both webview view providers route outbound messages through a defensive `safePost` helper that swallows `Webview is disposed` errors. This protects against late callbacks (search prefetches, upload progress, env-change refreshes) firing after a view collapses or the user dismisses an editor panel. + ## VS Code Integration Points -### Package.json Contributions +### `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 +| `viewsContainers.activitybar` | Cloudinary icon in the activity bar | +| `views.cloudinary` | Registers the homescreen and library webview views | +| `commands` | Command definitions used by webviews and panels | -| Context Value | Description | -|---------------|-------------| -| `asset` | Media file (enables copy commands) | -| `folder` | Directory (enables upload to folder) | -| `loadMore` | Pagination trigger | -| `clearSearch` | Clear search results | +Most toolbar and context actions are now implemented inside the library webview rather than through `contributes.menus`. ## Error Handling -All Cloudinary API errors flow through `handleCloudinaryError()`: +Cloudinary-facing failures should go through `handleCloudinaryError()`: ```typescript -import { handleCloudinaryError } from '../utils/cloudinaryErrorHandler'; +import { handleCloudinaryError } from "../utils/cloudinaryErrorHandler"; try { - await cloudinary.search.execute(); + await cloudinaryService.searchAssets("hero"); } catch (err: any) { - handleCloudinaryError('Failed to search assets', err); + 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 +The handler normalizes Cloudinary SDK errors and surfaces user-facing actions for common credential problems. diff --git a/docs/project-structure.md b/docs/project-structure.md index 4de1ddd..3366833 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -1,23 +1,24 @@ # Project Structure -This document explains the organization of the Cloudinary VS Code Extension codebase. +This document describes the current organization of the Cloudinary VS Code extension after the move to webview-based sidebar views. ## Directory Overview -``` +```text cloudinary-vscode/ ├── src/ # TypeScript source code │ ├── extension.ts # Extension entry point -│ ├── commands/ # Command implementations -│ ├── tree/ # Tree view (sidebar) +│ ├── cloudinary/ # Service layer and SDK adapter +│ ├── commands/ # Command registrations and handlers │ ├── config/ # Configuration utilities │ ├── utils/ # Shared utilities -│ ├── webview/ # Webview design system +│ ├── webview/ # Webview hosts, client code, and design system │ └── test/ # Test files -├── dist/ # Bundled output (esbuild) -├── out/ # TypeScript output (for tests) +├── media/ # Built webview CSS and JS assets +├── dist/ # Bundled extension output +├── out/ # TypeScript output for tests ├── docs/ # Documentation -├── resources/ # Static assets (icons) +├── resources/ # Static assets and icons ├── package.json # Extension manifest ├── tsconfig.json # TypeScript configuration ├── esbuild.js # Build script @@ -28,193 +29,173 @@ cloudinary-vscode/ ### Entry Point -**`extension.ts`** - Extension lifecycle management +**`extension.ts`** initializes the shared runtime: + +- creates `CloudinaryService` +- loads Cloudinary environment credentials +- detects folder mode +- registers `HomescreenViewProvider` +- registers `LibraryWebviewViewProvider` +- registers commands against the shared service and providers + +### Cloudinary Layer (`src/cloudinary/`) -- `activate()` - Called when extension starts -- Creates `CloudinaryTreeDataProvider` -- Loads configuration -- Registers commands and tree view +| File | Purpose | +|------|---------| +| `cloudinaryService.ts` | Shared Cloudinary state and high-level operations | +| `cloudinarySdkAdapter.ts` | Adapter that wraps the Cloudinary SDK | +| `types.ts` | Shared Cloudinary-facing types | ### Commands (`src/commands/`) -Each file exports a registration function: +Each file exports a registration function. | File | Commands | Purpose | |------|----------|---------| -| `registerCommands.ts` | - | Central registration, imports all commands | +| `registerCommands.ts` | - | Central registration entry point | | `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 | +| `welcomeScreen.ts` | `cloudinary.openWelcomeScreen` | Welcome/onboarding panel | +| `searchAssets.ts` | `cloudinary.searchAssets` | Focus homescreen search | +| `copyCommands.ts` | `cloudinary.copyPublicId`, `cloudinary.copyUrl`, `cloudinary.copyOptimizedUrl` | Clipboard operations | | `switchEnvironment.ts` | `cloudinary.switchEnvironment` | Environment switching | -| `clearSearch.ts` | `cloudinary.clearSearch` | Clear search filter | -| `viewOptions.ts` | `cloudinary.setResourceFilter` | Filter by type | +| `clearSearch.ts` | `cloudinary.clearSearch` | Clear library search | +| `viewOptions.ts` | `cloudinary.viewOptions` | Filter and sort library contents | +| `configureAiTools.ts` | `cloudinary.configureAiTools` | AI tools setup flow | -### 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 +Command handlers now generally accept `CloudinaryService`, a narrow environment target, or a webview provider rather than a tree provider. ### Configuration (`src/config/`) | File | Purpose | |------|---------| -| `configUtils.ts` | Load/validate configuration files | -| `detectFolderMode.ts` | Detect dynamic vs fixed folder mode | +| `configUtils.ts` | Load and validate Cloudinary environment files | +| `detectFolderMode.ts` | Detect dynamic versus 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 | +| `cloudinaryErrorHandler.ts` | Consistent Cloudinary error handling | +| `userAgent.ts` | Extension user-agent generation | ### Webview System (`src/webview/`) -The webview module provides a design system for building consistent UIs: +The webview module now contains both sidebar views and the reusable UI system used by panels. -``` +```text src/webview/ -├── index.ts # Public exports -├── tokens.ts # Design tokens (colors, spacing) -├── baseStyles.ts # CSS reset, typography -├── icons.ts # Centralized SVG icons +├── homescreenView.ts # Sidebar homescreen host +├── libraryView.ts # Sidebar media library host ├── 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 +├── client/ # Browser-side TypeScript +│ ├── homescreen.ts +│ ├── library.ts # Library main client +│ ├── libraryIcons.ts # SVG glyphs (duplicate of host icons.ts subset) +│ ├── libraryTypes.ts # Duplicated message-protocol types +│ ├── libraryVirtualList.ts # Virtualization math (22px row height) +│ ├── libraryRowSplice.ts # Depth-aware splice helper +│ ├── libraryMenu.ts # Context menu +│ ├── libraryHoverPreview.ts # Hover thumbnail card with metadata +│ ├── preview.ts +│ ├── upload-widget.ts +│ └── welcome.ts +├── components/ # Shared styles/helpers for webviews +├── utils/ # Webview-side utilities +├── scripts/ # Host-side script entry definitions +├── baseStyles.ts +├── icons.ts +├── index.ts +└── tokens.ts ``` -See [Webview System](./webview-system.md) for detailed documentation. +`src/webview/client/` is bundled into `media/scripts/` by `esbuild.js`. ## Configuration Files ### `package.json` -Extension manifest defining: -- Extension metadata (name, version, publisher) -- Activation events -- Contributed commands, views, and menus -- Dependencies +Defines: + +- extension metadata +- sidebar view contributions +- command contributions +- build and test scripts + +The sidebar now contributes two webview views: `cloudinaryHomescreen` and `cloudinaryMediaLibrary`. ### `tsconfig.json` -TypeScript configuration: -- Strict mode enabled -- ES2022 target -- CommonJS modules (for VS Code) +TypeScript configuration for type checking and test compilation. ### `esbuild.js` -Build configuration: -- Entry: `src/extension.ts` -- Output: `dist/extension.js` -- Externals: `vscode` module -- Source maps enabled +Bundles the extension entry point into `dist/extension.js` and builds the webview client assets into `media/`. ## Output Directories ### `dist/` -Production bundle created by esbuild: -- `extension.js` - Bundled extension code -- `extension.js.map` - Source map +Bundled extension output used by VS Code. ### `out/` -TypeScript compilation output (for tests): -- Mirrors `src/` structure -- Used by test runner +Compiled test output used by the extension-host test runner. + +### `media/` + +Built JS and CSS consumed by webviews. ## Resources ### `resources/` -Static assets: -- `cloudinary_icon_blue.png` - Extension icon -- `icon-image.svg`, `icon-video.svg`, `icon-file.svg` - Tree item icons +Static extension assets such as the Cloudinary icon. ## Key Patterns ### Command Registration ```typescript -// src/commands/myCommand.ts function registerMyCommand( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider + cloudinaryService: CloudinaryService ) { context.subscriptions.push( vscode.commands.registerCommand("cloudinary.myCommand", async () => { + if (!cloudinaryService.cloudName) { + return; + } + // Implementation }) ); } -export default registerMyCommand; ``` -### Tree Item Creation +### Webview HTML Generation ```typescript -new CloudinaryItem( - 'Label', - vscode.TreeItemCollapsibleState.Collapsed, - 'folder', // type - { path: '/products' }, // data - cloudName, - dynamicFolders -); +import { + createWebviewDocument, + getScriptUri, + getStyleUri, +} from "../webview/webviewUtils"; + +view.webview.html = createWebviewDocument({ + title: "My View", + webview: view.webview, + extensionUri: context.extensionUri, + bodyContent: `
`, + additionalStyles: [getStyleUri(view.webview, context.extensionUri, "my-view.css")], + additionalScripts: [getScriptUri(view.webview, context.extensionUri, "my-view.js")], +}); ``` -### Webview HTML Generation +### Library Refresh ```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();", -}); +await libraryWebview?.refresh(); ``` +Use provider methods instead of relying on removed tree-provider APIs. diff --git a/docs/setup.md b/docs/setup.md index 903904a..47053ab 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -85,9 +85,9 @@ npm test After making changes, verify: - [ ] Extension activates without errors -- [ ] Tree view populates with folders/assets +- [ ] Media library populates with folders/assets - [ ] Commands work from command palette -- [ ] Context menus appear on correct items +- [ ] Library context menu appears on asset and folder rows - [ ] Webviews display correctly in light and dark themes - [ ] Upload functionality works - [ ] Error messages are user-friendly @@ -125,7 +125,7 @@ When debugging the extension: |---------|-------------| | `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: Open Welcome Guide` | Test the welcome screen | | `Cloudinary: Upload` | Test the upload panel | ## Troubleshooting @@ -136,7 +136,7 @@ When debugging the extension: 2. Verify `main` in package.json points to `dist/extension.js` 3. Run `npm run compile` to check for TypeScript errors -### Tree View Empty +### Media Library Empty 1. Check credentials in config file 2. Verify network connectivity @@ -147,4 +147,3 @@ When debugging the extension: 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 index 6b36580..a8c3f9d 100644 --- a/docs/webview-system.md +++ b/docs/webview-system.md @@ -1,23 +1,34 @@ # 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. +The extension uses a modular design system for building webview UIs. CSS is loaded from `media/styles/`, and browser-side TypeScript entry points are bundled into `media/scripts/` by `esbuild.js`. ## 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 +├── client/ # Browser-side TypeScript entry points +│ ├── common.ts # Shared webview utilities +│ ├── homescreen.ts # Homescreen sidebar client +│ ├── library.ts # Media library sidebar client +│ ├── preview.ts # Asset preview client +│ ├── upload-widget.ts # Upload panel client +│ └── welcome.ts # Welcome panel client ├── webviewUtils.ts # HTML generation and CSP helpers -├── icons.ts # Centralized SVG icons +├── icons.ts # Centralized host-side SVG icons └── utils/helpers.ts # Shared utilities (escapeHtml, etc.) + +media/ +├── styles/ # CSS loaded by webviews +│ ├── tokens.css # Design tokens (CSS custom properties) +│ ├── base.css # Reset, typography, utilities +│ ├── components.css # Component styles +│ └── library.css # Library-specific styling +└── scripts/ # Built browser bundles from src/webview/client/ + ├── homescreen.js + ├── library.js + ├── preview.js + ├── upload-widget.js + └── welcome.js ``` ## Creating a Webview @@ -37,12 +48,12 @@ function openMyPanel(context: vscode.ExtensionContext) { { enableScripts: true, localResourceRoots: [ - vscode.Uri.joinPath(context.extensionUri, "src", "webview", "media"), + vscode.Uri.joinPath(context.extensionUri, "media"), ], } ); - // Optional: Add custom script + // Optional: add a bundled client entry from media/scripts/. const myScriptUri = getScriptUri(panel.webview, context.extensionUri, "my-script.js"); panel.webview.html = createWebviewDocument({ @@ -52,7 +63,6 @@ function openMyPanel(context: vscode.ExtensionContext) { bodyContent: getMyContent(), bodyClass: "layout-centered", // Optional additionalScripts: [myScriptUri], // Optional - inlineScript: "initCommon(); initMyPanel();", // Required }); // Handle messages from webview @@ -64,10 +74,11 @@ function openMyPanel(context: vscode.ExtensionContext) { ### 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 +1. **Bundle client behavior from `src/webview/client/`** and add the entry point to `esbuild.js` +2. **Call `initCommon()` from the client entry** when you need tabs, copy buttons, collapsibles, or lightbox behavior +3. **Use `escapeHtml()`** for any dynamic content +4. **Import icons** from the centralized module +5. **Specify `localResourceRoots`** to allow loading external files ## CSS Architecture @@ -122,48 +133,48 @@ Styles for all UI components: | Drop Zone | `.drop-zone`, `.drop-zone--active` | | Lightbox | `.lightbox`, `.lightbox__content` | -## JavaScript Architecture +## Client Script Architecture -### Common Script (`common.js`) +### Common Client Module (`src/webview/client/common.ts`) -Shared functionality loaded by all webviews: +Shared functionality imported by webview client entries: -```javascript -// Initialize all common functionality -function initCommon() { - initVSCode(); // acquireVsCodeApi() - initTabs(); // Tab switching - initCopyButtons(); // Copy to clipboard - initCollapsibles();// Expandable sections - initLightbox(); // Image lightbox -} +```typescript +import { initCommon, getVSCode } from "./common"; -// Utility functions -function copyToClipboard(text) { ... } -function formatFileSize(bytes) { ... } -function truncateString(str, maxLength) { ... } +initCommon(); +const vscode = getVSCode(); ``` +`initCommon()` initializes the VS Code API bridge plus shared tabs, copy buttons, collapsibles, and lightbox behavior. The module also exports helpers such as `copyToClipboard(...)`, `formatFileSize(...)`, and `truncateString(...)`. + ### View-Specific Scripts Each webview can have its own script: | Script | Purpose | Init Function | |--------|---------|---------------| -| `upload-widget.js` | File uploads, progress, presets | `initUploadWidget(config)` | +| `homescreen.js` | Sidebar homescreen and AI tools setup | (auto-init) | +| `library.js` | Sidebar media library browsing UI | (auto-init) | +| `preview.js` | Asset preview panel behavior | (auto-init) | +| `upload-widget.js` | File uploads, progress, presets | `initUploadWidget(config)` for server-provided config | | `welcome.js` | Welcome screen interactions | (auto-init) | ### Initialization Pattern -```javascript -// In inline script -initCommon(); // Always first -initUploadWidget({ - cloudName: "my-cloud", - presets: [...] +```typescript +// src/webview/client/my-panel.ts +import { initCommon, getVSCode } from "./common"; + +initCommon(); + +document.getElementById("myButton")?.addEventListener("click", () => { + getVSCode()?.postMessage({ command: "doSomething" }); }); ``` +Use `inlineScript` only when the extension host must pass runtime data into the already-loaded client bundle, as the upload panel does with `initUploadWidget({ ... })`. + ## Content Security Policy The `createWebviewDocument` function generates a secure CSP: @@ -317,8 +328,8 @@ panel.webview.onDidReceiveMessage((message) => { ## 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` +1. **Add styles** to the relevant file in `media/styles/` +2. **Add TypeScript** (if needed) to `src/webview/client/` 3. **Add TypeScript generator** (if complex) to `src/webview/components/` 4. **Export** from `src/webview/components/index.ts` diff --git a/esbuild.js b/esbuild.js index c32bb96..b8b5f69 100644 --- a/esbuild.js +++ b/esbuild.js @@ -26,6 +26,7 @@ async function main() { "src/webview/client/upload-widget.ts", "src/webview/client/welcome.ts", "src/webview/client/homescreen.ts", + "src/webview/client/library.ts", ], bundle: true, format: "iife", @@ -60,7 +61,7 @@ const esbuildProblemMatcherPlugin = { console.error(`✘ [ERROR] ${text}`); if (location == null) return; console.error( - ` ${location.file}:${location.line}:${location.column}:` + ` ${location.file}:${location.line}:${location.column}:`, ); }); console.log("[watch] build finished"); diff --git a/media/styles/components.css b/media/styles/components.css index 31aa053..054a9ac 100644 --- a/media/styles/components.css +++ b/media/styles/components.css @@ -350,6 +350,7 @@ .badge { display: inline-flex; align-items: center; + gap: 0.25rem; padding: 0.15rem 0.5rem; font-size: var(--font-xs); font-weight: 500; @@ -364,6 +365,18 @@ color: white; } +.badge--auth { + background-color: color-mix(in srgb, var(--vscode-charts-yellow, #cca700) 18%, var(--vscode-badge-background)); + color: var(--vscode-foreground); + border: 1px solid color-mix(in srgb, var(--vscode-charts-yellow, #cca700) 45%, transparent); +} + +.badge svg { + flex: 0 0 auto; + width: 0.85rem; + height: 0.85rem; +} + .badge--pill { border-radius: var(--radius-full); } /* ======================================== @@ -768,6 +781,7 @@ display: flex; align-items: center; gap: var(--space-sm); + flex-wrap: wrap; } /* Hero section */ diff --git a/media/styles/homescreen.css b/media/styles/homescreen.css index c0eb507..1fbdf5f 100644 --- a/media/styles/homescreen.css +++ b/media/styles/homescreen.css @@ -2,9 +2,15 @@ * Homescreen sidebar panel styles. */ -* { box-sizing: border-box; margin: 0; padding: 0; } +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} -body { background: var(--vscode-sideBar-background); } +body { + background: var(--vscode-sideBar-background); +} .hs-root { display: flex; @@ -23,21 +29,27 @@ body { background: var(--vscode-sideBar-background); } 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); + 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); + bottom: -32px; + left: 30px; + width: 70px; + height: 70px; + background: rgba(255, 255, 255, 0.04); border-radius: 50%; pointer-events: none; } @@ -50,18 +62,38 @@ body { background: var(--vscode-sideBar-background); } 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-logo-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 22px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(255, 255, 255, 0.38); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + flex-shrink: 0; +} + +.hs-brand-logo { + display: block; + width: 19px; + height: auto; +} + .hs-brand-name { font-size: 11px; font-weight: 700; letter-spacing: 1.2px; text-transform: uppercase; - color: rgba(255,255,255,0.95); + color: rgba(255, 255, 255, 0.95); } .hs-cloud-row { @@ -70,12 +102,14 @@ body { background: var(--vscode-sideBar-background); } 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; @@ -85,42 +119,48 @@ body { background: var(--vscode-sideBar-background); } white-space: nowrap; max-width: 160px; } + .hs-cloud-name--placeholder { font-size: 13px; font-weight: 400; - color: rgba(255,255,255,0.6); + color: rgba(255, 255, 255, 0.6); font-style: italic; } + .hs-folder-mode { font-size: 10px; - color: rgba(255,255,255,0.5); + 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); + 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); + 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); + background: rgba(255, 255, 255, 0.2); color: #fff; } + .hs-configure-btn:focus-visible { - outline: 1px solid rgba(255,255,255,0.5); + outline: 1px solid rgba(255, 255, 255, 0.5); outline-offset: 1px; } + .hs-status-pill { display: inline-flex; align-items: center; @@ -130,20 +170,23 @@ body { background: var(--vscode-sideBar-background); } font-size: 10px; font-weight: 500; letter-spacing: 0.2px; - color: rgba(255,255,255,0.92); - background: rgba(255,255,255,0.14); + color: rgba(255, 255, 255, 0.92); + background: rgba(255, 255, 255, 0.14); flex-shrink: 0; } + .hs-status-dot { - width: 6px; height: 6px; + width: 6px; + height: 6px; border-radius: 50%; background: #4ade80; - box-shadow: 0 0 5px rgba(74,222,128,0.8); + 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); + box-shadow: 0 0 5px rgba(251, 191, 36, 0.8); } /* ── Setup banner ── */ @@ -151,18 +194,27 @@ body { background: var(--vscode-sideBar-background); } 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); + 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; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--vscode-notificationsWarningIcon-foreground, #fbbf24); flex-shrink: 0; - line-height: 1; } + +.hs-setup-banner-icon svg { + width: 13px; + height: 13px; +} + .hs-setup-banner-text { flex: 1; font-size: 11px; @@ -170,44 +222,52 @@ body { background: var(--vscode-sideBar-background); } 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); + 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); } + +.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: 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; @@ -219,9 +279,11 @@ body { background: var(--vscode-sideBar-background); } padding: 5px 0; min-width: 0; } + .hs-search-input::placeholder { color: var(--vscode-input-placeholderForeground); } + .hs-search-clear { background: none; border: none; @@ -235,7 +297,11 @@ body { background: var(--vscode-sideBar-background); } border-radius: 3px; transition: color 0.1s; } -.hs-search-clear:hover { color: var(--vscode-foreground); } + +.hs-search-clear:hover { + color: var(--vscode-foreground); +} + .hs-search-clear:focus-visible { outline: 1px solid var(--vscode-focusBorder); } @@ -265,32 +331,61 @@ body { background: var(--vscode-sideBar-background); } text-align: left; transition: background 0.12s ease; } -.hs-action:hover { background: var(--vscode-list-hoverBackground); } + +.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:disabled:hover { + background: transparent; +} .hs-action-icon { - width: 30px; height: 30px; + 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-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; @@ -300,6 +395,7 @@ body { background: var(--vscode-sideBar-background); } overflow: hidden; text-overflow: ellipsis; } + .hs-action-desc { font-size: 10.5px; color: var(--vscode-descriptionForeground); @@ -318,9 +414,9 @@ body { background: var(--vscode-sideBar-background); } text-transform: uppercase; padding: 2px 6px; border-radius: 4px; - background: rgba(139,92,246,0.14); + background: rgba(139, 92, 246, 0.14); color: #8b5cf6; - border: 1px solid rgba(139,92,246,0.2); + border: 1px solid rgba(139, 92, 246, 0.2); } .hs-chevron { @@ -329,20 +425,24 @@ body { background: var(--vscode-sideBar-background); } opacity: 0.4; transition: transform 0.2s; } -.hs-chevron--open { transform: rotate(90deg); } + +.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)); + 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)); + 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; @@ -356,17 +456,33 @@ body { background: var(--vscode-sideBar-background); } 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); } + +.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); } + 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 { + user-select: none; +} + #hs-btn-ai-tools.expanded { background: var(--vscode-list-hoverBackground); border-radius: 7px 7px 0 0; @@ -377,13 +493,15 @@ body { background: var(--vscode-sideBar-background); } 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); + background: rgba(255, 255, 255, 0.02); border-top: 1px solid transparent; } + .hs-ai-panel.open { max-height: 640px; - border-top-color: var(--vscode-panel-border, rgba(128,128,128,0.14)); + border-top-color: var(--vscode-panel-border, rgba(128, 128, 128, 0.14)); } + .hs-ai-panel-inner { padding: 10px 10px 12px; display: flex; @@ -424,10 +542,11 @@ body { background: var(--vscode-sideBar-background); } font-family: var(--vscode-font-family); color: var(--vscode-dropdown-foreground); background: var(--vscode-dropdown-background); - border: 1px solid var(--vscode-dropdown-border, rgba(128,128,128,0.3)); + border: 1px solid var(--vscode-dropdown-border, rgba(128, 128, 128, 0.3)); border-radius: 4px; cursor: pointer; } + .hs-ai-platform-select:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 1px; @@ -448,7 +567,7 @@ body { background: var(--vscode-sideBar-background); } align-items: center; gap: 2px; background: var(--vscode-input-background); - border: 1px solid var(--vscode-dropdown-border, rgba(128,128,128,0.3)); + border: 1px solid var(--vscode-dropdown-border, rgba(128, 128, 128, 0.3)); border-radius: 5px; padding: 2px; } @@ -467,14 +586,17 @@ body { background: var(--vscode-sideBar-background); } transition: background 0.12s, color 0.12s; white-space: nowrap; } + .hs-ai-scope-btn:hover { background: var(--vscode-list-hoverBackground); color: var(--vscode-foreground); } + .hs-ai-scope-btn.active { background: var(--vscode-button-background); color: var(--vscode-button-foreground); } + .hs-ai-scope-btn:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; @@ -500,24 +622,41 @@ body { background: var(--vscode-sideBar-background); } } /* Loading skeletons */ -.hs-ai-loading { display: flex; flex-direction: column; gap: 6px; } +.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: 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; } + +.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; } + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } } /* Section headers */ @@ -533,11 +672,12 @@ body { background: var(--vscode-sideBar-background); } 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)); + background: var(--vscode-panel-border, rgba(128, 128, 128, 0.14)); } /* Checklist items */ @@ -551,15 +691,41 @@ body { background: var(--vscode-sideBar-background); } 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; } + +.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); } + from { + opacity: 0; + transform: translateX(-4px); + } + + to { + opacity: 1; + transform: translateX(0); + } } /* Custom checkbox */ @@ -576,20 +742,25 @@ body { background: var(--vscode-sideBar-background); } 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; + 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; @@ -615,6 +786,7 @@ body { background: var(--vscode-sideBar-background); } color: var(--vscode-descriptionForeground); white-space: nowrap; } + .hs-ai-item-status::before { content: ''; display: inline-block; @@ -623,14 +795,22 @@ body { background: var(--vscode-sideBar-background); } 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-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; @@ -639,7 +819,11 @@ body { background: var(--vscode-sideBar-background); } margin-top: 1px; } -.hs-ai-item input[type="checkbox"]:disabled { opacity: 0.5; cursor: default; } +.hs-ai-item input[type="checkbox"]:disabled { + opacity: 0.5; + cursor: default; +} + .hs-ai-hint { font-size: 9px; color: var(--vscode-descriptionForeground); @@ -656,14 +840,28 @@ body { background: var(--vscode-sideBar-background); } align-items: center; justify-content: center; font-size: 9px; - animation: tick-in 0.2s cubic-bezier(0.34,1.56,0.64,1) both; + 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); } + 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); } -.hs-ai-item-tick--ok { color: #4ade80; } -.hs-ai-item-tick--err { color: var(--vscode-errorForeground); } /* Apply button */ .hs-ai-apply { @@ -683,16 +881,28 @@ body { background: var(--vscode-sideBar-background); } overflow: hidden; margin-top: 2px; } + .hs-ai-apply::after { content: ''; position: absolute; inset: 0; - background: rgba(255,255,255,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: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; @@ -704,8 +914,10 @@ body { background: var(--vscode-sideBar-background); } 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); + background: rgba(241, 76, 76, 0.08); + border: 1px solid rgba(241, 76, 76, 0.2); } -.hidden { display: none !important; } +.hidden { + display: none !important; +} \ No newline at end of file diff --git a/media/styles/library.css b/media/styles/library.css new file mode 100644 index 0000000..3891c7c --- /dev/null +++ b/media/styles/library.css @@ -0,0 +1,926 @@ +/** + * Cloudinary Media Library — sidebar webview. + * Considered utility: precise rhythm, brand accents, VS Code-native. + */ + +:root { + --lib-row-height: 22px; + --lib-slot: 18px; + --lib-accent: var(--vscode-textLink-foreground, var(--cld-sky-blue)); + --lib-accent-soft: color-mix(in srgb, var(--lib-accent) 14%, transparent); + --lib-accent-softer: color-mix(in srgb, var(--lib-accent) 8%, transparent); + --lib-accent-strong: color-mix(in srgb, var(--lib-accent) 26%, transparent); + --lib-header-bg: var(--vscode-sideBarSectionHeader-background, var(--vscode-sideBar-background)); + --lib-header-border: var(--vscode-sideBarSectionHeader-border, var(--vscode-widget-border, transparent)); + --lib-icon-muted: var(--vscode-icon-foreground); +} + +html, +body { + height: 100vh; +} + +body { + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + color: var(--vscode-sideBar-foreground, var(--vscode-foreground)); + background: var(--vscode-sideBar-background); +} + +/* ======================================== + Header (brand strip + toolbar) + ======================================== */ +.lib-header { + flex: 0 0 auto; + background: var(--lib-header-bg); + border-bottom: 1px solid var(--lib-header-border); +} + +/* Brand strip */ +.lib-brand { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px 6px; + border-bottom: 1px solid var(--lib-header-border); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); +} + +.lib-brand__logo-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 5px; + background: #fff; + border: 1px solid color-mix(in srgb, var(--lib-accent) 24%, transparent); + flex-shrink: 0; +} + +.lib-brand__logo { + display: block; + width: 14px; + height: auto; +} + +.lib-brand__name { + color: var(--vscode-foreground); + font-weight: 600; +} + +.lib-brand__env { + margin-left: auto; + padding: 1px 7px; + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 10px; + font-family: var(--vscode-editor-font-family, ui-monospace, 'SF Mono', Menlo, monospace); + font-size: 10px; + font-weight: 500; + letter-spacing: 0; + text-transform: none; + background: var(--lib-accent-soft); + color: var(--lib-accent); + transition: background-color 160ms ease; +} + +.lib-brand__env:empty { + display: none; +} + +.lib-brand__env:hover { + background: var(--lib-accent-strong); +} + +/* Toolbar */ +.lib-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + min-height: 40px; +} + +.lib-tb-group { + display: flex; + align-items: center; + gap: 2px; +} + +.lib-tb-group + .lib-tb-group { + margin-left: 6px; +} + +.lib-tb-group--utility { + margin-left: auto; +} + +.lib-tb-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: none; + border-radius: 6px; + color: var(--lib-icon-muted); + cursor: pointer; + position: relative; + transition: + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.lib-tb-btn svg { + display: block; + width: 16px; + height: 16px; + transition: transform 140ms ease; +} + +.lib-tb-btn:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.18)); + color: var(--vscode-foreground); +} + +.lib-tb-btn:hover svg { + transform: translateY(-0.5px); +} + +.lib-tb-btn:active { + transform: scale(0.94); +} + +.lib-tb-btn:focus-visible { + outline: 1.5px solid var(--lib-accent); + outline-offset: 1px; +} + +/* ======================================== + Filter / sort bar + ======================================== */ +.lib-filter { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px 8px; + border-top: 1px solid var(--lib-header-border); + animation: libFilterIn 140ms ease-out; +} + +@keyframes libFilterIn { + from { opacity: 0; transform: translateY(-2px); } + to { opacity: 1; transform: translateY(0); } +} + +.lib-filter__group { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + padding: 0 8px 0 10px; + height: 26px; + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); + border-radius: 5px; + cursor: pointer; + transition: border-color 140ms ease, box-shadow 140ms ease; +} + +.lib-filter__group:focus-within { + border-color: var(--lib-accent); + box-shadow: 0 0 0 1px var(--lib-accent-soft); +} + +.lib-filter__label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; +} + +.lib-filter__select { + flex: 1; + min-width: 0; + background: transparent; + border: none; + outline: none; + color: var(--vscode-input-foreground, var(--vscode-foreground)); + font-family: inherit; + font-size: var(--vscode-font-size); + cursor: pointer; + appearance: none; + padding: 0 14px 0 0; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23999' d='M2 4l3 3 3-3'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0 center; +} + +.lib-filter__select::-ms-expand { + display: none; +} + +/* ======================================== + Search bar + ======================================== */ +.lib-search { + padding: 6px 8px 8px; + border-top: 1px solid var(--lib-header-border); + animation: libSearchIn 140ms ease-out; +} + +@keyframes libSearchIn { + from { opacity: 0; transform: translateY(-2px); } + to { opacity: 1; transform: translateY(0); } +} + +.lib-search__wrap { + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px; + height: 26px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, var(--vscode-widget-border, transparent)); + border-radius: 5px; + transition: border-color 140ms ease, box-shadow 140ms ease; +} + +.lib-search__wrap:focus-within { + border-color: var(--lib-accent); + box-shadow: 0 0 0 1px var(--lib-accent-soft); +} + +.lib-search__icon { + flex-shrink: 0; + color: var(--vscode-input-placeholderForeground, var(--lib-icon-muted)); + opacity: 0.85; +} + +.lib-search__wrap:focus-within .lib-search__icon { + color: var(--lib-accent); + opacity: 1; +} + +.lib-search__input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + outline: none; + color: inherit; + font-family: inherit; + font-size: var(--vscode-font-size); + line-height: 1; + padding: 0; +} + +.lib-search__input::placeholder { + color: var(--vscode-input-placeholderForeground); + opacity: 0.7; +} + +.lib-search__clear { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + background: transparent; + border: none; + border-radius: 50%; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-size: 11px; + line-height: 1; + padding: 0; + transition: background-color 100ms ease, color 100ms ease; +} + +.lib-search__clear:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.18)); + color: var(--vscode-foreground); +} + +/* ======================================== + Error banner + ======================================== */ +.lib-error { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + background: var(--vscode-inputValidation-errorBackground); + color: var(--vscode-inputValidation-errorForeground, var(--vscode-foreground)); + border-bottom: 1px solid var(--vscode-inputValidation-errorBorder); + font-size: var(--vscode-font-size); + flex: 0 0 auto; + animation: libErrorIn 180ms ease-out; +} + +@keyframes libErrorIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.lib-error__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--vscode-inputValidation-errorBorder, var(--cld-pink)); + flex-shrink: 0; + animation: libErrorPulse 1.4s ease-in-out infinite; +} + +@keyframes libErrorPulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.3); opacity: 0.6; } +} + +.lib-error__msg { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lib-error__retry { + flex-shrink: 0; + background: transparent; + color: inherit; + border: 1px solid currentColor; + border-radius: 3px; + padding: 2px 10px; + font-family: inherit; + font-size: calc(var(--vscode-font-size) - 1px); + cursor: pointer; + opacity: 0.85; + transition: opacity 140ms ease, background-color 140ms ease; +} + +.lib-error__retry:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.08); +} + +/* ======================================== + List container + ======================================== */ +.lib-root { + flex: 1 1 auto; + position: relative; + overflow-y: auto; + overflow-x: hidden; + outline: none; + padding: 4px 0; +} + +/* ======================================== + Rows — 22px precision grid + ======================================== */ +.lib-row { + display: flex; + align-items: center; + gap: 6px; + height: var(--lib-row-height); + padding: 0 10px 0 4px; + box-sizing: border-box; + cursor: pointer; + user-select: none; + white-space: nowrap; + color: var(--vscode-sideBar-foreground, var(--vscode-foreground)); + position: relative; +} + +.lib-row::before { + content: ''; + position: absolute; + left: 0; + top: 2px; + bottom: 2px; + width: 2px; + border-radius: 0 2px 2px 0; + background: transparent; + transition: background-color 120ms ease; +} + +.lib-row:hover { + background: var(--vscode-list-hoverBackground); +} + +.lib-row--selected, +.lib-row--selected:hover { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.lib-row--selected::before { + background: var(--lib-accent); +} + +.lib-root:focus-within .lib-row--selected { + background: var(--vscode-list-focusBackground, var(--vscode-list-activeSelectionBackground)); +} + +.lib-indent { + display: inline-block; + flex: 0 0 auto; + height: 1px; +} + +/* Fixed-width slots for perfect alignment */ +.lib-twistie, +.lib-twistie-spacer, +.lib-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--lib-slot); + height: var(--lib-row-height); + flex: 0 0 var(--lib-slot); +} + +.lib-twistie { + color: var(--lib-icon-muted); + cursor: pointer; + opacity: 0.75; +} + +.lib-twistie svg { + display: block; + transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1); + transform-origin: 50% 50%; +} + +.lib-twistie--open svg { + transform: rotate(90deg); +} + +.lib-row--folder:hover .lib-twistie { + opacity: 1; +} + +/* Icon colors — semantic per type */ +.lib-icon { + color: var(--lib-icon-muted); +} + +.lib-icon svg { + display: block; +} + +.lib-icon--folder { + color: color-mix(in srgb, var(--lib-accent) 75%, var(--lib-icon-muted)); +} + +.lib-row--folder-open .lib-icon--folder { + color: var(--lib-accent); +} + +.lib-row--selected .lib-twistie, +.lib-row--selected .lib-icon, +.lib-row--selected .lib-icon--folder { + color: var(--vscode-list-activeSelectionForeground); +} + +.lib-row--image .lib-icon--asset { + color: color-mix(in srgb, var(--cld-teal) 60%, var(--lib-icon-muted)); +} + +.lib-row--video .lib-icon--asset { + color: color-mix(in srgb, var(--cld-pink) 55%, var(--lib-icon-muted)); +} + +.lib-row--raw .lib-icon--asset { + color: var(--lib-icon-muted); + opacity: 0.7; +} + +.lib-row--authenticated .lib-icon--asset { + color: color-mix(in srgb, var(--vscode-charts-yellow, #cca700) 65%, var(--lib-icon-muted)); +} + +/* Name */ +.lib-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--vscode-font-size); + line-height: normal; + letter-spacing: 0.01em; +} + +.lib-row--folder .lib-name { + font-weight: 500; +} + +.lib-row--folder-open .lib-name { + color: var(--vscode-foreground); +} + +.lib-auth-lock { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 12px; + width: 12px; + height: var(--lib-row-height); + color: var(--vscode-charts-yellow, #cca700); + opacity: 0.9; +} + +.lib-auth-lock svg { + display: block; +} + +/* Asset metadata — format · size, right-aligned */ +.lib-meta { + flex: 0 0 auto; + margin-left: 6px; + font-size: calc(var(--vscode-font-size) - 1.5px); + color: var(--vscode-descriptionForeground); + letter-spacing: 0.02em; + line-height: normal; + font-variant-numeric: tabular-nums; + white-space: nowrap; + opacity: 0.85; + transition: opacity 100ms ease; +} + +.lib-row:hover .lib-meta, +.lib-row--selected .lib-meta { + opacity: 1; +} + +.lib-row--selected .lib-meta { + color: inherit; +} + +.lib-row--selected .lib-auth-lock, +.lib-row--selected.lib-row--authenticated .lib-icon--asset { + color: inherit; +} + +/* Loading row */ +.lib-row--loading { + cursor: default; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.lib-row--loading:hover { + background: transparent; +} + +.lib-loader-dots { + display: inline-flex; + align-items: center; + gap: 3px; + width: var(--lib-slot); + height: var(--lib-row-height); + justify-content: center; + flex: 0 0 var(--lib-slot); +} + +.lib-loader-dots i { + width: 3px; + height: 3px; + border-radius: 50%; + background: currentColor; + opacity: 0.3; + animation: libDot 1.2s infinite ease-in-out; +} + +.lib-loader-dots i:nth-child(2) { animation-delay: 0.15s; } +.lib-loader-dots i:nth-child(3) { animation-delay: 0.3s; } + +@keyframes libDot { + 0%, 100% { opacity: 0.25; transform: scale(0.8); } + 50% { opacity: 1; transform: scale(1); } +} + +/* Clear-search chip row */ +.lib-row--clear { + padding: 0 10px 0 4px; +} + +.lib-row--clear::before { + display: none; +} + +.lib-row--clear:hover { + background: transparent; +} + +.lib-clear-chip { + display: inline-flex; + align-items: center; + gap: 5px; + height: 16px; + padding: 0 8px 0 6px; + border-radius: 10px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: calc(var(--vscode-font-size) - 1px); + font-weight: 500; + max-width: calc(100% - 12px); + transition: + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.lib-row--clear:hover .lib-clear-chip { + background: var(--lib-accent); + color: var(--vscode-editor-background); + transform: translateX(1px); +} + +.lib-clear-chip__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 10px; + height: 10px; +} + +.lib-clear-chip__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ======================================== + Context menu + ======================================== */ +.lib-menu { + position: fixed; + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 6px; + min-width: 200px; + max-width: 260px; + background: var(--vscode-menu-background, var(--vscode-editorWidget-background)); + color: var(--vscode-menu-foreground, var(--vscode-foreground)); + border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border, transparent)); + border-radius: 7px; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.16), + 0 10px 28px rgba(0, 0, 0, 0.32); + font-size: calc(var(--vscode-font-size) - 0.5px); + z-index: 1000; + overflow: hidden; + animation: libMenuIn 130ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.lib-menu::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent 0%, + var(--lib-accent-soft) 28%, + var(--lib-accent-soft) 72%, + transparent 100% + ); + pointer-events: none; +} + +@keyframes libMenuIn { + from { opacity: 0; transform: translateY(-3px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.lib-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 12px 7px 11px; + background: transparent; + border: none; + border-radius: 5px; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + position: relative; + white-space: nowrap; + transition: + background-color 100ms ease, + color 100ms ease, + transform 100ms ease; +} + +.lib-menu-item::before { + content: ''; + position: absolute; + left: 0; + top: 5px; + bottom: 5px; + width: 2px; + border-radius: 0 2px 2px 0; + background: transparent; + transition: background-color 100ms ease; +} + +.lib-menu-item__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex: 0 0 14px; + color: var(--lib-icon-muted); + opacity: 0.85; + transition: color 100ms ease, opacity 100ms ease; +} + +.lib-menu-item__icon svg { + display: block; + width: 14px; + height: 14px; +} + +.lib-menu-item__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: 0.01em; +} + +.lib-menu-item:hover, +.lib-menu-item--active { + background: var(--vscode-menu-selectionBackground, var(--vscode-list-activeSelectionBackground)); + color: var(--vscode-menu-selectionForeground, var(--vscode-list-activeSelectionForeground)); +} + +.lib-menu-item:hover::before, +.lib-menu-item--active::before { + background: var(--lib-accent); +} + +.lib-menu-item:hover .lib-menu-item__icon, +.lib-menu-item--active .lib-menu-item__icon { + color: inherit; + opacity: 1; +} + +.lib-menu-item:active { + transform: scale(0.99); +} + +.lib-menu-item:focus-visible { + outline: none; +} + +.lib-menu__divider { + height: 1px; + margin: 4px 6px; + background: var(--vscode-menu-separatorBackground, var(--vscode-widget-border, currentColor)); + opacity: 0.35; +} + +/* ======================================== + Empty state + ======================================== */ +.lib-empty { + padding: 24px 16px; + color: var(--vscode-descriptionForeground); + text-align: center; + font-size: var(--vscode-font-size); + flex: 0 0 auto; +} + +.lib-empty--welcome { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + margin: 18px 14px; + padding: 22px 18px 20px; + background: var(--vscode-editorWidget-background, var(--vscode-editor-background)); + border: 1px solid var(--vscode-widget-border, transparent); + border-radius: 10px; + position: relative; + overflow: hidden; +} + +.lib-empty--welcome::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient( + 90deg, + var(--cld-brand-blue) 0%, + var(--cld-sky-blue) 55%, + var(--cld-turquoise) 100% + ); +} + +.lib-empty__glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 10px; + background: var(--lib-accent-soft); + color: var(--lib-accent); +} + +.lib-empty__glyph svg { + width: 20px; + height: 20px; +} + +.lib-empty__title { + margin: 0; + font-size: var(--vscode-font-size); + line-height: 1.45; + color: var(--vscode-foreground); +} + +.lib-empty__cta { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px 14px; + border: none; + border-radius: 4px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + font-family: inherit; + font-size: var(--vscode-font-size); + font-weight: 500; + cursor: pointer; + transition: background-color 140ms ease, transform 140ms ease; +} + +.lib-empty__cta:hover { + background: var(--vscode-button-hoverBackground); + transform: translateY(-0.5px); +} + +.lib-empty__cta:focus-visible { + outline: 1.5px solid var(--lib-accent); + outline-offset: 2px; +} + +/* ======================================== + Reduced motion + ======================================== */ +@media (prefers-reduced-motion: reduce) { + .lib-tb-btn, + .lib-tb-btn svg, + .lib-twistie svg, + .lib-row::before, + .lib-loader-dots i, + .lib-error, + .lib-error__dot, + .lib-search, + .lib-search__wrap, + .lib-filter, + .lib-filter__group, + .lib-menu, + .lib-menu-item, + .lib-menu-item__icon, + .lib-clear-chip, + .lib-empty__cta, + .lib-brand__env { + transition: none; + animation: none; + } +} diff --git a/package.json b/package.json index d1e3ff7..0a5f21f 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ { "id": "cloudinaryMediaLibrary", "name": "", + "type": "webview", "when": "cloudinary.activeView == 'library'" } ] @@ -136,60 +137,7 @@ "title": "Configure AI Tools", "category": "Cloudinary" } - ], - "menus": { - "view/title": [ - { - "command": "cloudinary.showHomescreen", - "when": "view == cloudinaryMediaLibrary", - "group": "navigation@0" - }, - { - "command": "cloudinary.refresh", - "when": "view == cloudinaryMediaLibrary", - "group": "navigation@1" - }, - { - "command": "cloudinary.openUploadWidget", - "when": "view == cloudinaryMediaLibrary", - "group": "navigation@2" - }, - { - "command": "cloudinary.searchAssets", - "when": "view == cloudinaryMediaLibrary", - "group": "navigation@3" - }, - { - "command": "cloudinary.viewOptions", - "when": "view == cloudinaryMediaLibrary", - "group": "navigation@4" - }, - { - "command": "cloudinary.openGlobalConfig", - "when": "view == cloudinaryMediaLibrary", - "group": "navigation@5" - } - ], - "view/item/context": [ - { - "command": "cloudinary.copyUrl", - "when": "view == cloudinaryMediaLibrary && viewItem == 'asset'" - }, - { - "command": "cloudinary.copyPublicId", - "when": "view == cloudinaryMediaLibrary && viewItem == 'asset'" - }, - { - "command": "cloudinary.copyOptimizedUrl", - "when": "view == cloudinaryMediaLibrary && viewItem == 'asset'" - }, - { - "command": "cloudinary.uploadToFolder", - "when": "view == cloudinaryMediaLibrary && viewItem == 'folder'", - "group": "inline" - } - ] - } + ] }, "scripts": { "compile": "npm run check-types && node esbuild.js", diff --git a/src/aiToolsService.ts b/src/aiToolsService.ts index 3e0adb7..80dedbb 100644 --- a/src/aiToolsService.ts +++ b/src/aiToolsService.ts @@ -58,22 +58,37 @@ export function detectEditor(): EditorType { export function getMcpFilePath(editor: EditorType): string { switch (editor) { - case "cursor": return ".cursor/mcp.json"; - case "windsurf": return ".windsurf/mcp.json"; + 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"; + default: return ".vscode/mcp.json"; } } +export function getMcpRootKey(editor: EditorType): "servers" | "mcpServers" { + return editor === "vscode" ? "servers" : "mcpServers"; +} + +export function getEditorDisplayName(editor: EditorType): string | undefined { + const names: Partial> = { + vscode: "VS Code", cursor: "Cursor", windsurf: "Windsurf", antigravity: "Antigravity", + }; + return names[editor]; +} + +function stripTilde(dir: string): string { + return dir.replace(/^~\/?/, ""); +} + export function detectEditorPlatform(): string { const editor = detectEditor(); switch (editor) { - case "cursor": return "cursor"; - case "windsurf": return "windsurf"; + case "cursor": return "cursor"; + case "windsurf": return "windsurf"; case "antigravity": return "antigravity"; case "vscode": - default: return "github-copilot"; + default: return "github-copilot"; } } @@ -408,7 +423,7 @@ export async function installSkill( ): Promise { const base = scope === "global" ? os.homedir() : rootUri.fsPath; const rawDir = scope === "global" ? platform.globalSkillsDir : platform.skillsDir; - const dir = scope === "global" ? rawDir.replace(/^~\/?/, "") : rawDir; + const dir = scope === "global" ? stripTilde(rawDir) : rawDir; // Write SKILL.md (silent overwrite — matches `npx skills add -y` behaviour) const skillPath = path.join(dir, dirName, "SKILL.md"); @@ -466,7 +481,7 @@ export async function readInstalledSkillDirNames( } const projectDir = platform.skillsDir; - const globalDir = platform.globalSkillsDir.replace(/^~\/?/, ""); + const globalDir = stripTilde(platform.globalSkillsDir); const [project, global] = await Promise.all([ checkScope(projectBase, projectDir), @@ -546,8 +561,7 @@ export async function installMcpServers( createdFiles: string[] ): Promise { const mcpFilePath = getMcpFilePath(editor); - const isVscode = editor === "vscode"; - const rootKey = isVscode ? "servers" : "mcpServers"; + const rootKey = getMcpRootKey(editor); const mcpUri = vscode.Uri.joinPath(rootUri, mcpFilePath); let config: Record = {}; try { diff --git a/src/cloudinary/cloudinarySdkAdapter.ts b/src/cloudinary/cloudinarySdkAdapter.ts new file mode 100644 index 0000000..dc180b3 --- /dev/null +++ b/src/cloudinary/cloudinarySdkAdapter.ts @@ -0,0 +1,46 @@ +import { v2 as cloudinary } from 'cloudinary'; +import { CloudinarySdkAdapter } from './cloudinaryService'; +import { SortDirection } from './types'; + +/** + * Production adapter wired to the real Cloudinary SDK. + */ +export function createCloudinarySdkAdapter(): CloudinarySdkAdapter { + return { + subFolders: async (path: string) => { + return cloudinary.api.sub_folders(path) as unknown as { + folders: Array<{ name: string; path: string }>; + }; + }, + search: async (opts) => { + const query = cloudinary.search + .expression(opts.expression) + .sort_by(opts.sortBy.field, opts.sortBy.direction as SortDirection) + .max_results(opts.maxResults); + + if (opts.withField) { + query.with_field(opts.withField); + } + + if (opts.nextCursor) { + query.next_cursor(opts.nextCursor); + } + + const result = await query.execute(); + return { + resources: result.resources || [], + next_cursor: result.next_cursor || null, + }; + }, + uploadPresets: async () => { + return cloudinary.api.upload_presets({ max_results: 500 }) as unknown as { + presets: Array<{ + name: string; + unsigned: boolean; + settings?: Record; + }>; + }; + }, + urlFor: (publicId, opts) => cloudinary.url(publicId, opts as any), + }; +} diff --git a/src/cloudinary/cloudinaryService.ts b/src/cloudinary/cloudinaryService.ts new file mode 100644 index 0000000..5daa653 --- /dev/null +++ b/src/cloudinary/cloudinaryService.ts @@ -0,0 +1,290 @@ +import { + ChildrenPage, + ClientAsset, + ClientFolder, + FetchChildrenOpts, + SortDirection, + UploadPreset, +} from './types'; + +/** + * Narrow view of the Cloudinary SDK used by the service. Injecting this makes + * the service testable without loading the real SDK. + */ +export interface CloudinarySdkAdapter { + subFolders(path: string): Promise<{ folders: Array<{ name: string; path: string }> }>; + search(opts: { + expression: string; + sortBy: { field: string; direction: SortDirection }; + maxResults: number; + nextCursor?: string; + withField?: string[]; + }): Promise<{ resources: any[]; next_cursor: string | null }>; + uploadPresets(): Promise<{ presets: Array<{ name: string; unsigned: boolean; settings?: Record }> }>; + urlFor(publicId: string, opts: { + resource_type: string; + type?: string; + transformation?: unknown; + sign_url?: boolean; + secure?: boolean; + format?: string; + }): string; +} + +export interface Credentials { + cloudName: string | null; + apiKey: string | null; + apiSecret: string | null; + uploadPreset?: string | null; + dynamicFolders?: boolean; +} + +/** + * Pure data layer for the Cloudinary media library. + * No vscode.* imports. All methods return plain JSON. + */ +export class CloudinaryService { + cloudName: string | null = null; + apiKey: string | null = null; + apiSecret: string | null = null; + uploadPreset: string | null = null; + dynamicFolders = false; + uploadPresets: UploadPreset[] = []; + + constructor(private readonly sdk: CloudinarySdkAdapter) {} + + setCredentials(creds: Credentials): void { + this.cloudName = creds.cloudName; + this.apiKey = creds.apiKey; + this.apiSecret = creds.apiSecret; + this.uploadPreset = creds.uploadPreset ?? null; + this.dynamicFolders = creds.dynamicFolders ?? false; + } + + async fetchChildren(folderPath: string, opts: FetchChildrenOpts): Promise { + const expression = this.buildExpression(folderPath); + const [foldersResult, assetsResult] = await Promise.all([ + this.sdk.subFolders(folderPath), + this.sdk.search({ + expression, + sortBy: { field: 'created_at', direction: opts.sortDirection }, + maxResults: 500, + nextCursor: opts.nextCursor, + withField: ['tags', 'context', 'metadata'], + }), + ]); + + const folders: ClientFolder[] = (foldersResult.folders || []).map((f) => ({ + name: f.name, + path: f.path, + })); + + const filtered = (assetsResult.resources || []).filter((asset: any) => { + if (!this.dynamicFolders && folderPath === '' && typeof asset.public_id === 'string' && asset.public_id.includes('/')) { + return false; + } + if (opts.resourceTypeFilter === 'all') { return true; } + return String(asset.resource_type).toLowerCase() === opts.resourceTypeFilter; + }); + + const assets: ClientAsset[] = filtered.map((a: any) => this.toClientAsset(a)); + + return { folders, assets, nextCursor: assetsResult.next_cursor ?? null }; + } + + private buildExpression(folderPath: string): string { + if (this.dynamicFolders) { + return folderPath ? `asset_folder="${folderPath}"` : 'asset_folder=""'; + } + // Fixed-folder root: empty expression returns ALL resources. fetchChildren + // then filters nested assets client-side. See the `public_id.includes('/')` + // guard in the resource filter. + return folderPath ? `folder="${folderPath}"` : ''; + } + + /** + * Streams remaining pages for a folder via callback. Caller can append to its + * own cache / forward over postMessage per batch. Stops when cursor is null + * or `cap` filtered assets have been delivered. + * + * NOTE: `cap` counts post-filter assets delivered to `onBatch`, not raw + * server resources. In fixed-folder-root mode with heavy client-side + * filtering, the actual API call volume may exceed `cap`. + */ + async prefetchRemaining( + folderPath: string, + startCursor: string, + opts: FetchChildrenOpts, + onBatch: (assets: ClientAsset[], hasMore: boolean) => void, + cap: number = 5000, + ): Promise { + let cursor: string | null = startCursor; + let total = 0; + const expression = this.buildExpression(folderPath); + while (cursor && total < cap) { + const result: { resources: any[]; next_cursor: string | null } = await this.sdk.search({ + expression, + sortBy: { field: 'created_at', direction: opts.sortDirection }, + maxResults: 500, + nextCursor: cursor, + withField: ['tags', 'context', 'metadata'], + }); + const filtered = (result.resources || []).filter((asset: any) => { + if (!this.dynamicFolders && folderPath === '' && typeof asset.public_id === 'string' && asset.public_id.includes('/')) { + return false; + } + if (opts.resourceTypeFilter === 'all') { return true; } + return String(asset.resource_type).toLowerCase() === opts.resourceTypeFilter; + }); + const assets = filtered.map((a: any) => this.toClientAsset(a)); + cursor = result.next_cursor ?? null; + if (assets.length === 0) { continue; } + total += assets.length; + onBatch(assets, !!cursor && total < cap); + } + } + + async searchAssets(query: string, opts: FetchChildrenOpts): Promise<{ assets: ClientAsset[]; nextCursor: string | null }> { + const result = await this.sdk.search({ + expression: `${query}*`, + sortBy: { field: 'created_at', direction: opts.sortDirection }, + maxResults: 500, + withField: ['tags', 'context', 'metadata'], + }); + const assets = this.filterByResourceType(result.resources || [], opts.resourceTypeFilter) + .map((a: any) => this.toClientAsset(a)); + return { assets, nextCursor: result.next_cursor ?? null }; + } + + async prefetchSearchResults( + query: string, + startCursor: string, + opts: FetchChildrenOpts, + onBatch: (assets: ClientAsset[], hasMore: boolean) => void, + cap: number = 5000, + ): Promise { + let cursor: string | null = startCursor; + let total = 0; + while (cursor && total < cap) { + const result: { resources: any[]; next_cursor: string | null } = await this.sdk.search({ + expression: `${query}*`, + sortBy: { field: 'created_at', direction: opts.sortDirection }, + maxResults: 500, + nextCursor: cursor, + withField: ['tags', 'context', 'metadata'], + }); + const assets = this.filterByResourceType(result.resources || [], opts.resourceTypeFilter) + .map((a: any) => this.toClientAsset(a)); + cursor = result.next_cursor ?? null; + if (assets.length === 0) { continue; } + total += assets.length; + onBatch(assets, !!cursor && total < cap); + } + } + + private filterByResourceType(resources: any[], filter: FetchChildrenOpts['resourceTypeFilter']): any[] { + return resources.filter((asset: any) => { + if (filter === 'all') { + return true; + } + return String(asset.resource_type).toLowerCase() === filter; + }); + } + + private toClientAsset(a: any): ClientAsset { + // Coerce unexpected resource_type values to 'image'; mirrors prior tree-provider fallback. + const resourceType = (a.resource_type === 'video' || a.resource_type === 'raw') ? a.resource_type : 'image'; + const deliveryType = typeof a.type === 'string' ? a.type : undefined; + const isAuthenticated = deliveryType === 'authenticated'; + const baseUrlOpts = { + resource_type: resourceType, + type: deliveryType, + secure: true, + }; + + const signedOriginal = isAuthenticated + ? this.sdk.urlFor(a.public_id, { + ...baseUrlOpts, + sign_url: true, + ...(resourceType !== 'raw' && a.format ? { format: String(a.format) } : {}), + }) + : undefined; + + const optimized = signedOriginal || this.sdk.urlFor(a.public_id, { + ...baseUrlOpts, + transformation: resourceType === 'raw' + ? undefined + : [{ fetch_format: resourceType === 'video' ? 'auto:video' : 'auto' }, { quality: 'auto' }], + }); + const thumbnail = signedOriginal || (resourceType === 'raw' + ? optimized + : this.sdk.urlFor(a.public_id, { + ...baseUrlOpts, + transformation: [{ width: 160, height: 160, crop: 'fill', fetch_format: 'auto', quality: 'auto' }], + })); + return { + public_id: a.public_id, + display_name: a.display_name, + resource_type: resourceType, + type: deliveryType, + format: a.format, + bytes: a.bytes, + width: a.width, + height: a.height, + secure_url: signedOriginal || a.secure_url, + optimized_url: optimized, + thumbnail_url: thumbnail, + tags: a.tags, + context: a.context, + metadata: a.metadata, + created_at: a.created_at, + }; + } + + async fetchUploadPresets(): Promise { + if (!this.cloudName || !this.apiKey || !this.apiSecret) { + throw new Error('Cloudinary credentials not configured'); + } + const result = await this.sdk.uploadPresets(); + this.uploadPresets = result.presets.map((p) => ({ + name: p.name, + signed: p.unsigned === false, + settings: p.settings, + })); + return this.uploadPresets; + } + + async listAllFolders(maxDepth = 4): Promise> { + const all: Array<{ path: string; name: string }> = []; + + const visitLevel = async (paths: string[], depth: number): Promise => { + if (paths.length === 0 || depth > maxDepth) { + return; + } + + const results = await Promise.all( + paths.map((p) => this.sdk.subFolders(p)) + ); + + const next: string[] = []; + for (const result of results) { + for (const folder of result.folders || []) { + all.push({ path: folder.path, name: folder.name }); + next.push(folder.path); + } + } + + await visitLevel(next, depth + 1); + }; + + await visitLevel([''], 0); + return all.sort((a, b) => a.path.localeCompare(b.path)); + } + + getCurrentUploadPreset(): string | null { + if (this.uploadPreset && this.uploadPresets.some((p) => p.name === this.uploadPreset)) { + return this.uploadPreset; + } + return null; + } +} diff --git a/src/cloudinary/types.ts b/src/cloudinary/types.ts new file mode 100644 index 0000000..319655f --- /dev/null +++ b/src/cloudinary/types.ts @@ -0,0 +1,51 @@ +/** + * Plain-JSON asset record passed between service, host bridge, and webview. + * Intentionally flat and free of vscode.* types so it can be postMessage'd. + */ +export interface ClientAsset { + public_id: string; + display_name?: string; + resource_type: 'image' | 'video' | 'raw'; + /** Cloudinary delivery type: 'upload' | 'authenticated' | 'private' | 'fetch'. */ + type?: string; + format?: string; + bytes?: number; + width?: number; + height?: number; + secure_url: string; + /** Pre-computed delivery URL with f_auto,q_auto. Authenticated assets use the signed original URL. */ + optimized_url: string; + /** Pre-computed thumbnail URL. Authenticated assets use the signed original URL instead of dynamic transforms. */ + thumbnail_url: string; + tags?: string[]; + context?: Record; + metadata?: Record; + created_at?: string; +} + +export interface ClientFolder { + name: string; + path: string; +} + +export interface ChildrenPage { + folders: ClientFolder[]; + assets: ClientAsset[]; + nextCursor: string | null; +} + +export type ResourceTypeFilter = 'all' | 'image' | 'video' | 'raw'; +export type SortDirection = 'asc' | 'desc'; + +export interface FetchChildrenOpts { + resourceTypeFilter: ResourceTypeFilter; + sortDirection: SortDirection; + /** Pass a cursor from a previous ChildrenPage. Omit (or pass `undefined`) to start from the beginning. */ + nextCursor?: string; +} + +export interface UploadPreset { + name: string; + signed: boolean; + settings?: Record; +} diff --git a/src/commands/clearSearch.ts b/src/commands/clearSearch.ts index 4e67424..f5774c6 100644 --- a/src/commands/clearSearch.ts +++ b/src/commands/clearSearch.ts @@ -1,26 +1,24 @@ import * as vscode from "vscode"; -import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { LibraryWebviewViewProvider } from "../webview/libraryView"; + +type SearchAwareLibraryWebview = LibraryWebviewViewProvider & { + setSearch?: (query: string | null) => Thenable | Promise; +}; /** * Registers a command that clears the active search filter in the Cloudinary view. * @param context - The VS Code extension context. - * @param provider - The Cloudinary tree data provider instance. + * @param libraryWebview - The Cloudinary library webview provider. */ function registerClearSearch( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider + libraryWebview?: LibraryWebviewViewProvider ) { context.subscriptions.push( - vscode.commands.registerCommand("cloudinary.clearSearch", () => { - provider.refresh({ - folderPath: '', - nextCursor: null, - searchQuery: null, - resourceTypeFilter: 'all' - }); - vscode.window.showInformationMessage("🔍 Search filter cleared."); + vscode.commands.registerCommand("cloudinary.clearSearch", async () => { + await (libraryWebview as SearchAwareLibraryWebview | undefined)?.setSearch?.(null); }) ); } -export default registerClearSearch; \ No newline at end of file +export default registerClearSearch; diff --git a/src/commands/configureAiTools.ts b/src/commands/configureAiTools.ts index d4af0c4..23554c6 100644 --- a/src/commands/configureAiTools.ts +++ b/src/commands/configureAiTools.ts @@ -6,6 +6,7 @@ import { MCP_SERVERS, detectEditor, getMcpFilePath, + getMcpRootKey, fetchSkillList, fetchSkillContent, installForClaudeCode, @@ -25,8 +26,7 @@ async function createMcpConfig( mcpFilePath: string, createdFiles: string[] ): Promise { - const rootKey = editor === "vscode" ? "servers" : "mcpServers"; - const configuredKeys = await readConfiguredMcpServerKeys(rootUri, mcpFilePath, rootKey); + const configuredKeys = await readConfiguredMcpServerKeys(rootUri, mcpFilePath, getMcpRootKey(editor)); const selected = await vscode.window.showQuickPick( MCP_SERVERS.map((s) => ({ @@ -85,13 +85,13 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { const editor = detectEditor(); const ideOptions: vscode.QuickPickItem[] = [ { label: "Claude Code", description: "Install to .claude/skills/" }, - { label: "Cursor", description: "Install to .cursor/rules/" }, + { 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"; + editor === "vscode" ? "VS Code (Copilot)" : + "Claude Code"; const qp = vscode.window.createQuickPick(); qp.items = ideOptions; @@ -171,7 +171,7 @@ function registerConfigureAiTools(context: vscode.ExtensionContext): void { if (createdFiles.length > 0) { const action = await vscode.window.showInformationMessage( - `✅ Configured AI tools: ${createdFiles.join(", ")}`, + `$(check) Configured AI tools: ${createdFiles.join(", ")}`, "Open File" ); if (action === "Open File") { diff --git a/src/commands/previewAsset.ts b/src/commands/previewAsset.ts index 29d208d..de5fbd5 100644 --- a/src/commands/previewAsset.ts +++ b/src/commands/previewAsset.ts @@ -14,6 +14,7 @@ type AssetData = { filename: string; format?: string; resource_type?: string; + type?: string; tags?: string[]; context?: Record; metadata?: Record; @@ -135,6 +136,17 @@ function getAssetTypeIcon(type: string): string { return getAssetIcon(type); } +function isAuthenticatedAsset(asset: AssetData): boolean { + return asset.type === "authenticated"; +} + +function buildAuthenticatedBadgeHtml(asset: AssetData): string { + if (!isAuthenticatedAsset(asset)) { + return ""; + } + return `${actionIcons.lock("sm")} Authenticated`; +} + /** * Build the preview section HTML based on asset type. */ @@ -219,6 +231,30 @@ function buildMetadataHtml(data: Record | undefined, emptyText: str .join(""); } +function buildUrlItem(label: string, url: string): string { + return ` +
+
${escapeHtml(label)}
+
+ ${escapeHtml(url)} + +
+
+ `; +} + +function buildUrlsHtml(asset: AssetData): string { + if (isAuthenticatedAsset(asset)) { + const signedUrl = asset.secure_url || asset.optimized_url; + const extraOptimized = asset.optimized_url && asset.optimized_url !== signedUrl + ? buildUrlItem("Optimized URL", asset.optimized_url) + : ""; + return `${buildUrlItem("Signed authenticated URL", signedUrl)}${extraOptimized}`; + } + + return `${buildUrlItem("Original URL", asset.secure_url)}${buildUrlItem("Optimized URL", asset.optimized_url)}`; +} + /** * Build the lightbox HTML. */ @@ -250,6 +286,8 @@ function getPreviewContent(asset: AssetData): string { const contextHtml = buildMetadataHtml(asset.context, "No context metadata"); const metadataHtml = buildMetadataHtml(asset.metadata, "No structured metadata"); const lightboxHtml = hasEnlarge ? buildLightboxHtml(asset) : ""; + const authenticatedBadgeHtml = buildAuthenticatedBadgeHtml(asset); + const urlsHtml = buildUrlsHtml(asset); return `
@@ -261,6 +299,7 @@ function getPreviewContent(asset: AssetData): string {

${escapeHtml(displayName || "")}

${escapeHtml((asset.format || asset.displayType || "unknown").toUpperCase())} + ${authenticatedBadgeHtml} ${asset.width && asset.height ? `${asset.width} × ${asset.height}` : ""} ${asset.bytes ? ` • ${formatFileSize(asset.bytes)}` : ""}
@@ -301,6 +340,10 @@ function getPreviewContent(asset: AssetData): string { Type ${escapeHtml(asset.displayType || "unknown")}
+
+ Delivery + ${escapeHtml(asset.type || "upload")}${authenticatedBadgeHtml} +
@@ -319,20 +362,7 @@ function getPreviewContent(asset: AssetData): string {
-
-
Original URL
- -
-
-
Optimized URL
- -
+ ${urlsHtml}
diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index 46929f4..5437641 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -8,21 +8,24 @@ import registerSwitchEnv from "./switchEnvironment"; import registerClearSearch from "./clearSearch"; import registerWelcomeScreen from "./welcomeScreen"; import registerConfigureAiTools from "./configureAiTools"; -import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { CloudinaryService } from "../cloudinary/cloudinaryService"; import { HomescreenViewProvider } from "../webview/homescreenView"; +import { LibraryWebviewViewProvider } from "../webview/libraryView"; /** * Registers all Cloudinary-related commands with the VS Code command registry. * @param context - The extension context. - * @param provider - The Cloudinary tree data provider. + * @param cloudinaryService - The shared Cloudinary service. * @param statusBar - Status bar item to show current environment. * @param homescreenProvider - The homescreen webview view provider. */ function registerAllCommands( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider, + cloudinaryService: CloudinaryService, + environmentTarget: Parameters[1], statusBar: vscode.StatusBarItem, - homescreenProvider: HomescreenViewProvider + homescreenProvider: HomescreenViewProvider, + libraryWebview?: LibraryWebviewViewProvider ) { context.subscriptions.push( vscode.commands.registerCommand("cloudinary.showHomescreen", () => { @@ -38,24 +41,19 @@ function registerAllCommands( ); context.subscriptions.push( - vscode.commands.registerCommand("cloudinary.refresh", () => - provider.refresh({ - folderPath: '', - nextCursor: null, - searchQuery: null, - resourceTypeFilter: 'all' - }) - ) + vscode.commands.registerCommand("cloudinary.refresh", async () => { + await libraryWebview?.refresh(); + }) ); - registerSearch(context, provider, homescreenProvider); - registerClearSearch(context, provider); - registerViewOptions(context, provider); + registerSearch(context, homescreenProvider); + registerClearSearch(context, libraryWebview); + registerViewOptions(context, libraryWebview); registerPreview(context); - registerUpload(context, provider); + registerUpload(context, cloudinaryService); registerClipboard(context); - registerSwitchEnv(context, provider, statusBar); - registerWelcomeScreen(context, provider); + registerSwitchEnv(context, environmentTarget, statusBar); + registerWelcomeScreen(context, cloudinaryService); registerConfigureAiTools(context); } diff --git a/src/commands/searchAssets.ts b/src/commands/searchAssets.ts index f432f34..bf88462 100644 --- a/src/commands/searchAssets.ts +++ b/src/commands/searchAssets.ts @@ -1,13 +1,8 @@ import * as vscode from "vscode"; -import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; import { HomescreenViewProvider } from "../webview/homescreenView"; -/** - * Registers the search command. Opens the dashboard and focuses its search input. - */ function registerSearch( context: vscode.ExtensionContext, - _provider: CloudinaryTreeDataProvider, homescreenProvider: HomescreenViewProvider ) { context.subscriptions.push( diff --git a/src/commands/switchEnvironment.ts b/src/commands/switchEnvironment.ts index 8600975..3b8c8c5 100644 --- a/src/commands/switchEnvironment.ts +++ b/src/commands/switchEnvironment.ts @@ -1,8 +1,8 @@ import * as vscode from "vscode"; import { v2 as cloudinary } from "cloudinary"; +import { CloudinaryService, Credentials } from "../cloudinary/cloudinaryService"; import { loadEnvironments, getGlobalConfigPath } from "../config/configUtils"; import detectFolderMode from "../config/detectFolderMode"; -import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; import { generateUserAgent } from "../utils/userAgent"; interface CloudinaryEnvironment { @@ -11,15 +11,49 @@ interface CloudinaryEnvironment { uploadPreset?: string; // Optional: Default upload preset } +type EnvironmentTarget = Pick< + CloudinaryService, + "cloudName" | "apiKey" | "apiSecret" | "uploadPreset" | "dynamicFolders" +> & { + setCredentials?: (creds: Credentials) => void; +}; + +function updateEnvironmentTarget( + target: EnvironmentTarget, + credentials: Credentials +) { + if (typeof target.setCredentials === "function") { + target.setCredentials(credentials); + return; + } + + target.cloudName = credentials.cloudName; + target.apiKey = credentials.apiKey; + target.apiSecret = credentials.apiSecret; + target.uploadPreset = credentials.uploadPreset ?? null; + target.dynamicFolders = credentials.dynamicFolders ?? false; +} + +function getStatusBarText(cloudName: string, dynamicFolders: boolean): string { + const folderMode = dynamicFolders ? "Dynamic" : "Fixed"; + return `$(cloud) ${cloudName} $(folder) ${folderMode}`; +} + +function getStatusBarTooltip(dynamicFolders: boolean): string { + return dynamicFolders + ? "Click to switch Cloudinary environment\n\nDynamic Folders: Assets can be organized independently of their public ID" + : "Click to switch Cloudinary environment\n\nFixed Folders: Asset folder is determined by public ID path"; +} + /** * Registers commands for switching Cloudinary environments and editing global config. * @param context - Extension context (includes global state). - * @param provider - Cloudinary data provider to update credentials. + * @param target - Cloudinary environment target to update credentials on. * @param statusBar - VS Code status bar item to reflect environment change. */ function registerSwitchEnv( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider, + target: EnvironmentTarget, statusBar: vscode.StatusBarItem ) { context.subscriptions.push( @@ -30,7 +64,7 @@ function registerSwitchEnv( const cloudNames = Object.keys(environments); if (cloudNames.length === 0) { - vscode.window.showErrorMessage("❌ No Cloudinary environments found in config."); + vscode.window.showErrorMessage("No Cloudinary environments found in config."); return; } @@ -40,26 +74,29 @@ function registerSwitchEnv( if (selected) { const env = environments[selected]; - - provider.cloudName = selected; - provider.apiKey = env.apiKey; - provider.apiSecret = env.apiSecret; - provider.uploadPreset = env.uploadPreset || null; - const cacheKey = `cloudinary.dynamicFolders.${selected}`; const cachedFolderMode = context.globalState.get(cacheKey) as boolean | undefined; + let dynamicFolders: boolean; if (typeof cachedFolderMode === "boolean") { - provider.dynamicFolders = cachedFolderMode; + dynamicFolders = cachedFolderMode; } else { - provider.dynamicFolders = await detectFolderMode( + dynamicFolders = await detectFolderMode( selected, env.apiKey, env.apiSecret ); - context.globalState.update(cacheKey, provider.dynamicFolders); + await context.globalState.update(cacheKey, dynamicFolders); } + updateEnvironmentTarget(target, { + cloudName: selected, + apiKey: env.apiKey, + apiSecret: env.apiSecret, + uploadPreset: env.uploadPreset || null, + dynamicFolders, + }); + (cloudinary.utils as any).userPlatform = generateUserAgent(); cloudinary.config({ @@ -68,24 +105,11 @@ function registerSwitchEnv( api_secret: env.apiSecret, }); - // Update status bar with folder mode indicator - const folderMode = provider.dynamicFolders ? "Dynamic" : "Fixed"; - statusBar.text = `$(cloud) ${selected} $(folder) ${folderMode}`; - statusBar.tooltip = provider.dynamicFolders - ? "Click to switch Cloudinary environment\n\nDynamic Folders: Assets can be organized independently of their public ID" - : "Click to switch Cloudinary environment\n\nFixed Folders: Asset folder is determined by public ID path"; - - provider.refresh({ - folderPath: '', - nextCursor: null, - searchQuery: null, - resourceTypeFilter: 'all' - }); - - provider.notifyEnvironmentChange(); + statusBar.text = getStatusBarText(selected, dynamicFolders); + statusBar.tooltip = getStatusBarTooltip(dynamicFolders); vscode.window.showInformationMessage( - `🔄 Switched to ${selected} environment.` + `$(cloud) Switched to ${selected} environment.` ); } } diff --git a/src/commands/uploadWidget.ts b/src/commands/uploadWidget.ts index f510c9e..f3c4117 100644 --- a/src/commands/uploadWidget.ts +++ b/src/commands/uploadWidget.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode"; -import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { CloudinaryService } from "../cloudinary/cloudinaryService"; +import { UploadPreset } from "../cloudinary/types"; import { v2 as cloudinary } from "cloudinary"; import { Readable } from "stream"; import { @@ -16,6 +17,30 @@ interface FolderOption { label: string; } +type UploadWidgetCloudinaryState = Pick< + CloudinaryService, + "cloudName" | "apiKey" | "apiSecret" | "uploadPresets" | "dynamicFolders" +> & { + fetchUploadPresets(): Promise; + getCurrentUploadPreset(): string | null; + listAllFolders(maxDepth?: number): Promise>; +}; + +type UploadWidgetCloudinaryStateTarget = + | UploadWidgetCloudinaryState + | { + service?: UploadWidgetCloudinaryState; + }; + +function hasUploadWidgetCloudinaryState( + value: UploadWidgetCloudinaryStateTarget +): value is UploadWidgetCloudinaryState { + return ( + "fetchUploadPresets" in value && + typeof value.fetchUploadPresets === "function" + ); +} + /** * Singleton panel instance for the upload widget. */ @@ -36,18 +61,64 @@ const CHUNKED_UPLOAD_THRESHOLD = 20 * 1024 * 1024; */ const UPLOAD_CHUNK_SIZE = 6 * 1024 * 1024; +function getUploadAssetResourceType(asset: any): string { + return asset.resource_type || "raw"; +} + +function getSignedOriginalUrl(asset: any, resourceType: string): string | undefined { + if (asset.type !== "authenticated") { + return undefined; + } + + return cloudinary.url(asset.public_id, { + resource_type: resourceType, + type: asset.type, + secure: true, + sign_url: true, + ...(resourceType !== "raw" && asset.format ? { format: asset.format } : {}), + }); +} + +function getOptimizedUrl(asset: any, resourceType: string): string { + const signedOriginalUrl = getSignedOriginalUrl(asset, resourceType); + if (signedOriginalUrl) { + return signedOriginalUrl; + } + + return resourceType === "raw" + ? cloudinary.url(asset.public_id, { resource_type: "raw", type: asset.type, secure: true }) + : cloudinary.url(asset.public_id, { + resource_type: resourceType, + type: asset.type, + secure: true, + transformation: [ + { fetch_format: resourceType === "video" ? "auto:video" : "auto" }, + { quality: "auto" }, + ], + }); +} + +function withSignedAuthenticatedUrl(asset: any): any { + const resourceType = getUploadAssetResourceType(asset); + const signedOriginalUrl = getSignedOriginalUrl(asset, resourceType); + return signedOriginalUrl + ? { ...asset, secure_url: signedOriginalUrl } + : asset; +} + /** * Registers commands for the Cloudinary upload widget. */ function registerUpload( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider + cloudinaryStateTarget: UploadWidgetCloudinaryStateTarget ) { + const cloudinaryState = getUploadCloudinaryState(cloudinaryStateTarget); + context.subscriptions.push( vscode.commands.registerCommand("cloudinary.openUploadWidget", async () => { try { - await provider.fetchUploadPresets(); - openOrRevealUploadPanel("", provider, context); + await openOrRevealUploadPanel("", cloudinaryState, context); } catch (err: any) { vscode.window.showErrorMessage( `Failed to open upload widget: ${err.message}` @@ -61,9 +132,8 @@ function registerUpload( "cloudinary.uploadToFolder", async (folderItem: { label: string; data: { path?: string } }) => { try { - await provider.fetchUploadPresets(); const folderPath = folderItem.data.path || ""; - openOrRevealUploadPanel(folderPath, provider, context); + await openOrRevealUploadPanel(folderPath, cloudinaryState, context); } catch (err: any) { vscode.window.showErrorMessage( `Failed to open upload widget: ${err.message}` @@ -87,32 +157,62 @@ export function resetUploadPanel(): void { uploadPanel = undefined; } +function getUploadCloudinaryState( + cloudinaryStateTarget: UploadWidgetCloudinaryStateTarget +): UploadWidgetCloudinaryState { + if (hasUploadWidgetCloudinaryState(cloudinaryStateTarget)) { + return cloudinaryStateTarget; + } + + const service = cloudinaryStateTarget.service; + if (!service) { + throw new Error("Cloudinary service is unavailable"); + } + + return service; +} + /** * Opens the upload panel or reveals it if already open. */ -function openOrRevealUploadPanel( +async function openOrRevealUploadPanel( folderPath: string, - provider: CloudinaryTreeDataProvider, + cloudinaryState: UploadWidgetCloudinaryState, context: vscode.ExtensionContext -) { +): Promise { currentFolderPath = folderPath; if (uploadPanel) { - uploadPanel.reveal(vscode.ViewColumn.One); - uploadPanel.webview.postMessage({ - command: "setFolder", - folderPath: folderPath, - }); - return; + try { + uploadPanel.reveal(vscode.ViewColumn.One); + uploadPanel.webview.postMessage({ + command: "setFolder", + folderPath: folderPath, + }); + return; + } catch { + uploadPanel = undefined; + } } - uploadPanel = createUploadPanel(provider, context); + uploadPanel = await createUploadPanel(cloudinaryState, context); uploadPanel.onDidDispose(() => { uploadPanel = undefined; }); } +/** + * Posts a message to a webview panel, swallowing errors if the panel was disposed. + */ +function safePostToPanel(panel: vscode.WebviewPanel, message: unknown): void { + try { + panel.webview.postMessage(message); + } catch { + // Panel disposed between post calls; safe to ignore. + } +} + /** * Uploads a file with progress tracking. */ @@ -129,12 +229,12 @@ async function uploadWithProgress( return new Promise((resolve, reject) => { const uploadStream = useChunkedUpload ? cloudinary.uploader.upload_chunked_stream( - { ...options, chunk_size: UPLOAD_CHUNK_SIZE }, - (error, result) => (error ? reject(error) : resolve(result)) - ) + { ...options, chunk_size: UPLOAD_CHUNK_SIZE }, + (error, result) => (error ? reject(error) : resolve(result)) + ) : cloudinary.uploader.upload_stream(options, (error, result) => - error ? reject(error) : resolve(result) - ); + error ? reject(error) : resolve(result) + ); let uploaded = 0; const total = buffer.length; @@ -146,7 +246,7 @@ async function uploadWithProgress( if (chunk.length > 0) { uploaded += chunk.length; const percent = Math.round((uploaded / total) * 100); - panel.webview.postMessage({ + safePostToPanel(panel, { command: "uploadProgress", fileId, percent, @@ -163,13 +263,27 @@ async function uploadWithProgress( } /** - * Collects folder options from the provider's cache. + * Collects folder options from the Cloudinary service. */ -function collectFolderOptions(provider: CloudinaryTreeDataProvider): FolderOption[] { +async function collectFolderOptions( + cloudinaryState: UploadWidgetCloudinaryState +): Promise { const folders: FolderOption[] = [{ path: "", label: "/ (root)" }]; - for (const folder of provider.getAvailableFolders()) { - folders.push({ path: folder.path, label: folder.path }); + const seenPaths = new Set(); + + try { + for (const folder of await cloudinaryState.listAllFolders()) { + if (!folder.path || seenPaths.has(folder.path)) { + continue; + } + + seenPaths.add(folder.path); + folders.push({ path: folder.path, label: folder.path }); + } + } catch { + return folders; } + return folders; } @@ -177,7 +291,7 @@ function collectFolderOptions(provider: CloudinaryTreeDataProvider): FolderOptio * Creates upload options for the Cloudinary API. */ function getUploadOptions( - provider: CloudinaryTreeDataProvider, + cloudinaryState: UploadWidgetCloudinaryState, presetName: string | null | undefined, folder: string, publicId?: string, @@ -191,7 +305,7 @@ function getUploadOptions( } if (folder) { - if (provider.dynamicFolders) { + if (cloudinaryState.dynamicFolders) { options.asset_folder = folder; } else { options.folder = folder; @@ -200,7 +314,7 @@ function getUploadOptions( if (fileName) { options.filename_override = fileName; - if (provider.dynamicFolders) { + if (cloudinaryState.dynamicFolders) { options.display_name = fileName.replace(/\.[^/.]+$/, ""); } } @@ -222,11 +336,11 @@ function getUploadOptions( /** * Creates the webview panel with custom upload UI. */ -function createUploadPanel( - provider: CloudinaryTreeDataProvider, +async function createUploadPanel( + cloudinaryState: UploadWidgetCloudinaryState, context: vscode.ExtensionContext -): vscode.WebviewPanel { - const cloudName = provider.cloudName!; +): Promise { + const cloudName = cloudinaryState.cloudName!; const panel = vscode.window.createWebviewPanel( "cloudinaryUploadWidget", `Upload — ${cloudName}`, @@ -246,8 +360,8 @@ function createUploadPanel( "cloudinary_icon_blue.png" ); - const currentPreset = provider.getCurrentUploadPreset() || ""; - const folders = collectFolderOptions(provider); + const currentPreset = cloudinaryState.getCurrentUploadPreset() || ""; + const initialFolders: FolderOption[] = [{ path: "", label: "/ (root)" }]; const uploadScriptUri = getScriptUri( panel.webview, @@ -255,8 +369,9 @@ function createUploadPanel( "upload-widget.js" ); - // Configuration to pass to the webview - const presetsJson = JSON.stringify(provider.uploadPresets); + // Configuration to pass to the webview. Render with cached presets/folders + // immediately; refresh asynchronously below to avoid blocking the panel open. + const presetsJson = JSON.stringify(cloudinaryState.uploadPresets); const initScript = ` initUploadWidget({ cloudName: ${JSON.stringify(cloudName)}, @@ -268,7 +383,12 @@ function createUploadPanel( title: "Upload to Cloudinary", webview: panel.webview, extensionUri: context.extensionUri, - bodyContent: getUploadContent(currentPreset, provider, currentFolderPath, folders), + bodyContent: getUploadContent( + currentPreset, + cloudinaryState.uploadPresets, + currentFolderPath, + initialFolders + ), bodyClass: "layout-centered", additionalScripts: [uploadScriptUri], inlineScript: initScript, @@ -285,7 +405,7 @@ function createUploadPanel( const presetName = message.preset !== undefined ? message.preset : currentPreset; const folder = message.folderPath !== undefined ? message.folderPath : currentFolderPath; const options = getUploadOptions( - provider, + cloudinaryState, presetName, folder, message.publicId, @@ -294,13 +414,15 @@ function createUploadPanel( ); try { - panel.webview.postMessage({ command: "uploadStarted", fileId: message.fileId }); + safePostToPanel(panel, { command: "uploadStarted", fileId: message.fileId }); - const result = await uploadWithProgress(panel, message.dataUri, options, message.fileId); + const result = withSignedAuthenticatedUrl( + await uploadWithProgress(panel, message.dataUri, options, message.fileId) + ); result._uploadedToFolder = folder || "(root)"; result._originalFileName = message.fileName; - panel.webview.postMessage({ + safePostToPanel(panel, { command: "uploadComplete", fileId: message.fileId, asset: result, @@ -308,7 +430,7 @@ function createUploadPanel( vscode.commands.executeCommand("cloudinary.refresh"); } catch (err: any) { - panel.webview.postMessage({ + safePostToPanel(panel, { command: "uploadError", fileId: message.fileId, error: err.message || "Upload failed", @@ -333,7 +455,7 @@ function createUploadPanel( } const options = getUploadOptions( - provider, + cloudinaryState, presetName, folder, message.publicId, @@ -343,17 +465,19 @@ function createUploadPanel( const fileId = message.fileId || `url-${Date.now()}`; try { - panel.webview.postMessage({ command: "uploadStarted", fileId, fileName: message.url }); - panel.webview.postMessage({ command: "uploadProgress", fileId, percent: 50 }); + safePostToPanel(panel, { command: "uploadStarted", fileId, fileName: message.url }); + safePostToPanel(panel, { command: "uploadProgress", fileId, percent: 50 }); - const result = await cloudinary.uploader.upload(message.url, options); + const result = withSignedAuthenticatedUrl( + await cloudinary.uploader.upload(message.url, options) + ); result._uploadedToFolder = folder || "(root)"; result._originalFileName = urlFileName; - panel.webview.postMessage({ command: "uploadComplete", fileId, asset: result }); + safePostToPanel(panel, { command: "uploadComplete", fileId, asset: result }); vscode.commands.executeCommand("cloudinary.refresh"); } catch (err: any) { - panel.webview.postMessage({ + safePostToPanel(panel, { command: "uploadError", fileId, error: err.message || "Upload failed", @@ -367,25 +491,15 @@ function createUploadPanel( } if (message.command === "refreshFolders") { - const updatedFolders = collectFolderOptions(provider); - panel.webview.postMessage({ command: "updateFolders", folders: updatedFolders }); + const updatedFolders = await collectFolderOptions(cloudinaryState); + safePostToPanel(panel, { command: "updateFolders", folders: updatedFolders }); } if (message.command === "openAsset" && message.asset) { const asset = message.asset; - const assetType = asset.resource_type || "raw"; - - const optimizedUrl = - assetType === "raw" - ? cloudinary.url(asset.public_id, { resource_type: "raw", type: asset.type }) - : cloudinary.url(asset.public_id, { - resource_type: assetType, - type: asset.type, - transformation: [ - { fetch_format: assetType === "video" ? "auto:video" : "auto" }, - { quality: "auto" }, - ], - }); + const assetType = getUploadAssetResourceType(asset); + const signedOriginalUrl = getSignedOriginalUrl(asset, assetType); + const optimizedUrl = getOptimizedUrl(asset, assetType); const filename = asset._originalFileName || @@ -396,12 +510,36 @@ function createUploadPanel( vscode.commands.executeCommand("cloudinary.openAsset", { ...asset, displayType: assetType, + secure_url: signedOriginalUrl || asset.secure_url, optimized_url: optimizedUrl, filename, }); } }); + // Deferred load: fetch presets and folders in parallel without blocking the + // initial render. Posts updates to the client when each completes. + void (async () => { + const presetsTask = cloudinaryState + .fetchUploadPresets() + .then((presets) => { + safePostToPanel(panel, { command: "updatePresets", presets }); + }) + .catch((err) => { + console.error("Failed to fetch upload presets:", err); + }); + + const foldersTask = collectFolderOptions(cloudinaryState) + .then((folders) => { + safePostToPanel(panel, { command: "updateFolders", folders }); + }) + .catch((err) => { + console.error("Failed to load folders:", err); + }); + + await Promise.all([presetsTask, foldersTask]); + })(); + return panel; } @@ -410,7 +548,7 @@ function createUploadPanel( */ function getUploadContent( currentPreset: string, - provider: CloudinaryTreeDataProvider, + uploadPresets: UploadPreset[], initialFolderPath: string, folders: FolderOption[] ): string { @@ -420,7 +558,7 @@ function getUploadContent( ) .join(""); - const presetOptionsHtml = provider.uploadPresets + const presetOptionsHtml = uploadPresets .map( (p) => `` ) @@ -442,7 +580,7 @@ function getUploadContent(
- ${provider.uploadPresets.length > 0 ? '' : ""} + ${uploadPresets.length > 0 ? '' : ""}
+ + + + + + + +
+ + +
+ `, + additionalStyles: [cssUri], + additionalScripts: [scriptUri], + }); + + view.webview.onDidReceiveMessage((message) => { + void this.handleMessage(message); + }); + } + + private async handleMessage(message: any): Promise { + switch (message?.command) { + case 'ready': + this.post({ + command: 'envChanged', + cloudName: this._service.cloudName || '', + folderMode: this._service.dynamicFolders ? 'dynamic' : 'fixed', + hasConfig: this.hasCredentials(), + }); + await this.refreshCurrentView(); + return; + case 'expandFolder': + await this.sendFolder(String(message.path || '')); + return; + case 'openAsset': + if (message.asset) { + const asset = message.asset as ClientAsset; + const filename = + asset.display_name || + asset.public_id.split('/').pop() || + asset.public_id; + void vscode.commands.executeCommand('cloudinary.openAsset', { + ...asset, + displayType: asset.resource_type, + filename, + }); + } + return; + case 'runToolbar': { + const action = String(message.action || ''); + const commandMap: Record = { + refresh: 'cloudinary.refresh', + openUploadWidget: 'cloudinary.openUploadWidget', + showHomescreen: 'cloudinary.showHomescreen', + openGlobalConfig: 'cloudinary.openGlobalConfig', + }; + const command = commandMap[action]; + if (command) { + void vscode.commands.executeCommand(command); + } + return; + } + case 'contextAction': { + const action = String(message.action || ''); + if ( + action === 'copyUrl' || + action === 'copyPublicId' || + action === 'copyOptimizedUrl' + ) { + void vscode.commands.executeCommand(`cloudinary.${action}`, { + data: message.data, + }); + } else if (action === 'uploadToFolder') { + void vscode.commands.executeCommand('cloudinary.uploadToFolder', { + label: message.data?.name || '', + data: message.data, + }); + } + return; + } + case 'clearSearch': + await this.setSearch(null); + return; + case 'searchAssets': { + const query = typeof message.query === 'string' ? message.query : ''; + await this.setSearch(query); + return; + } + case 'setView': { + await this.applyView({ + resourceTypeFilter: message.resourceTypeFilter, + sortDirection: message.sortDirection, + }); + return; + } + default: + return; + } + } + + async setSearch(query: string | null): Promise { + this._viewState.searchQuery = query && query.trim().length > 0 + ? query.trim() + : null; + try { + await this.refreshCurrentView(); + } catch (err: any) { + handleCloudinaryError('Failed to search library', err); + this.post({ + command: 'error', + message: err?.message || 'Failed to search library', + }); + } + } + + async applyView(opts: { + resourceTypeFilter?: ResourceTypeFilter; + sortDirection?: SortDirection; + }): Promise { + if (opts.resourceTypeFilter) { + this._viewState.resourceTypeFilter = opts.resourceTypeFilter; + } + if (opts.sortDirection) { + this._viewState.sortDirection = opts.sortDirection; + } + + this._cache.clear(); + this.post({ + command: 'viewStateChanged', + resourceTypeFilter: this._viewState.resourceTypeFilter, + sortDirection: this._viewState.sortDirection, + }); + + try { + await this.refreshCurrentView(); + } catch (err: any) { + handleCloudinaryError('Failed to apply library view options', err); + this.post({ + command: 'error', + message: err?.message || 'Failed to refresh library view', + }); + } + } + + async refresh(): Promise { + this._cache.clear(); + try { + await this.refreshCurrentView(); + } catch (err: any) { + handleCloudinaryError('Failed to refresh library', err); + this.post({ + command: 'error', + message: err?.message || 'Failed to refresh library', + }); + } + } + + async envChanged(): Promise { + this._cache.clear(); + this._viewState = { + folderPath: '', + searchQuery: null, + resourceTypeFilter: 'all', + sortDirection: 'desc', + }; + this.post({ + command: 'envChanged', + cloudName: this._service.cloudName || '', + folderMode: this._service.dynamicFolders ? 'dynamic' : 'fixed', + hasConfig: this.hasCredentials(), + }); + this.post({ + command: 'viewStateChanged', + resourceTypeFilter: this._viewState.resourceTypeFilter, + sortDirection: this._viewState.sortDirection, + }); + await this.sendRoot(); + } + + private async sendRoot(): Promise { + if (!this.hasCredentials()) { + return; + } + + const page = await this.loadFolder(''); + this.post({ + command: 'rootData', + folders: page.folders, + assets: page.assets, + hasMore: !!page.nextCursor, + }); + + if (page.nextCursor) { + this.startPrefetch('', page.nextCursor); + } + } + + private async sendSearch(query: string): Promise { + if (!this.hasCredentials()) { + return; + } + + try { + const result = await this._service.searchAssets(query, { + resourceTypeFilter: this._viewState.resourceTypeFilter, + sortDirection: this._viewState.sortDirection, + }); + + this.post({ + command: 'searchData', + query, + assets: result.assets, + hasMore: !!result.nextCursor, + }); + + if (result.nextCursor) { + this.startSearchPrefetch(query, result.nextCursor); + } + } catch (err: any) { + handleCloudinaryError('Failed to search library', err); + this.post({ + command: 'error', + message: err?.message || 'Failed to search library', + }); + } + } + + private async sendFolder(path: string): Promise { + if (!this.hasCredentials()) { + return; + } + + const page = await this.loadFolder(path); + this.post({ + command: 'folderData', + path, + folders: page.folders, + assets: page.assets, + hasMore: !!page.nextCursor, + }); + + if (page.nextCursor) { + this.startPrefetch(path, page.nextCursor); + } + } + + private hasCredentials(): boolean { + const service = this._service; + if (!service.cloudName || !service.apiKey || !service.apiSecret) { + return false; + } + + if (isPlaceholderConfig(service.cloudName, service.apiKey, service.apiSecret)) { + return false; + } + + return true; + } + + private async loadFolder(path: string): Promise { + const cached = this._cache.get(path); + if (cached) { + return cached; + } + + try { + const page = await this._service.fetchChildren(path, { + resourceTypeFilter: this._viewState.resourceTypeFilter, + sortDirection: this._viewState.sortDirection, + }); + this._cache.set(path, page); + return page; + } catch (err: any) { + handleCloudinaryError('Failed to fetch folders or assets', err); + this.post({ + command: 'error', + message: err?.message || 'Failed to load library', + }); + return { folders: [], assets: [], nextCursor: null }; + } + } + + private async refreshCurrentView(): Promise { + if (this._viewState.searchQuery) { + await this.sendSearch(this._viewState.searchQuery); + return; + } + + await this.sendRoot(); + } + + private startPrefetch(path: string, startCursor: string): void { + if (this._prefetchingFolders.has(path)) { + return; + } + + this._prefetchingFolders.add(path); + + this._service.prefetchRemaining( + path, + startCursor, + { + resourceTypeFilter: this._viewState.resourceTypeFilter, + sortDirection: this._viewState.sortDirection, + }, + (assets, hasMore) => { + const entry = this._cache.get(path); + if (entry) { + entry.assets = entry.assets.concat(assets); + entry.nextCursor = hasMore ? entry.nextCursor : null; + } + + this.post({ command: 'assetsAppended', path, assets, hasMore }); + } + ) + .catch((err) => { + console.error('Prefetch error:', err); + }) + .finally(() => { + this._prefetchingFolders.delete(path); + }); + } + + private startSearchPrefetch(query: string, startCursor: string): void { + const key = `search:${query}`; + if (this._prefetchingFolders.has(key)) { + return; + } + + this._prefetchingFolders.add(key); + + this._service.prefetchSearchResults( + query, + startCursor, + { + resourceTypeFilter: this._viewState.resourceTypeFilter, + sortDirection: this._viewState.sortDirection, + }, + (assets, hasMore) => { + this.post({ command: 'searchAppended', assets, hasMore }); + } + ) + .catch((err) => { + console.error(err); + }) + .finally(() => { + this._prefetchingFolders.delete(key); + }); + } + + private post(message: unknown): void { + const view = this._view; + if (!view) { + return; + } + try { + view.webview.postMessage(message); + } catch { + // Webview disposed between checks; safe to drop. + } + } +} diff --git a/src/webview/scripts/welcomeScreen.ts b/src/webview/scripts/welcomeScreen.ts index 30994c2..bb3b40c 100644 --- a/src/webview/scripts/welcomeScreen.ts +++ b/src/webview/scripts/welcomeScreen.ts @@ -16,8 +16,8 @@ export function getWelcomeScreenScript(): string { vscode.postMessage({ command: 'openExternal', data: url }); } - function focusTreeView() { - vscode.postMessage({ command: 'focusTreeView' }); + function focusDashboard() { + vscode.postMessage({ command: 'focusDashboard' }); } function getConfigExample() {