diff --git a/.gitignore b/.gitignore index b0dd8bb..5d974dd 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/CHANGELOG.md b/CHANGELOG.md index c0c2f2b..a8e303d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to the Cloudinary VS Code extension will be documented in th The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] - 2026-06-30 + +### Added +- **Docs AI assistant** - In-editor chat with Cloudinary documentation. Includes chat history, source citations, and a dedicated webview panel. +- **Homescreen sidebar** - Redesigned home view with search, environment switcher, recent conversations, and an AI tools panel. +- **Configure AI Tools** - One-click installer for Cloudinary skills and MCP servers across Cursor, GitHub Copilot, Windsurf, Claude Code, and Universal targets. Scope-aware (global vs. workspace) with install-status detection. +- **Webview-based asset explorer** - Replaces the native VS Code tree view. Supports streaming pagination, search, and parallel fetching. +- **Analytics tracking** - Anonymous usage telemetry for commands and webview interactions. Sensitive payload keys are stripped before transmission; session id persisted in global state. +- **CI: VSIX build artifacts** - Feature branches now produce installable VSIX builds with PR-comment download links. + +### Changed +- **Welcome screen interactions** - Refreshed onboarding flow. +- **Cloudinary webview toolbar** - Unified toolbar styling across Docs AI, library, and homescreen. +- **Homescreen renders shell instantly** - Static shell paints first, data loads asynchronously. +- **AI tools platform detection** - Auto-detects installed AI platforms (Cursor, Copilot, etc.) on panel open; defaults to GitHub Copilot when running inside VS Code. + +### Fixed +- Preview and upload panels close on environment switch instead of attempting in-place update. +- Home recent-conversations list refreshes correctly after new chats. +- Upload panel title shows the active cloud name; presets refetched on environment switch. +- Docs AI prompt handoff no longer flashes during transition. + +## [0.1.5] + +Skipped — released as part of 1.0.0. + ## [0.1.4] - 2026-04-16 ### Added @@ -70,7 +96,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Asset refresh functionality - Support for both fixed and dynamic folder modes - Configurable upload presets -- Beta version with ongoing feature development +- General availability release baseline ### Technical - Built with TypeScript diff --git a/README.md b/README.md index 5f1ed41..f61343a 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# Cloudinary VS Code Extension (Beta) +# Cloudinary VS Code Extension Easily explore, search, preview, and upload Cloudinary assets directly inside Visual Studio Code or supported IDE. -**Note**: This is a beta version of the extension. Some features are subject to change. Please report any problems or feedback by opening an issue. +**General availability release**: Please report any problems or feedback by opening an issue. --- ## 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/RELEASE_v1.0.0.md b/RELEASE_v1.0.0.md new file mode 100644 index 0000000..04c6d36 --- /dev/null +++ b/RELEASE_v1.0.0.md @@ -0,0 +1,37 @@ +# Cloudinary VS Code Extension — v1.0.0 + +> General availability release. + +## What's new + +### Added + +- **Docs AI assistant** — In-editor chat with Cloudinary documentation. Includes chat history, source citations, and a dedicated webview panel. +- **Homescreen sidebar** — Redesigned home view with search, environment switcher, recent conversations, and an AI tools panel. +- **Configure AI Tools** — One-click installer for Cloudinary skills and MCP servers across Cursor, GitHub Copilot, Windsurf, Claude Code, and Universal targets. Scope-aware (global vs. workspace) with install-status detection. +- **Webview-based asset explorer** — Replaces the native VS Code tree view. Supports streaming pagination, search, and parallel fetching. +- **Analytics tracking** — Anonymous usage telemetry for commands and webview interactions. Sensitive payload keys are stripped before transmission; session id persisted in global state. +- **CI: VSIX build artifacts** — Feature branches now produce installable VSIX builds with PR-comment download links. + +### Changed + +- **Welcome screen interactions** — Refreshed onboarding flow. +- **Cloudinary webview toolbar** — Unified toolbar styling across Docs AI, library, and homescreen. +- **Homescreen renders shell instantly** — Static shell paints first, data loads asynchronously. +- **AI tools platform detection** — Auto-detects installed AI platforms (Cursor, Copilot, etc.) on panel open; defaults to GitHub Copilot when running inside VS Code. + +### Fixed + +- Preview and upload panels close on environment switch instead of attempting in-place update. +- Home recent-conversations list refreshes correctly after new chats. +- Upload panel title shows the active cloud name; presets refetched on environment switch. +- Docs AI prompt handoff no longer flashes during transition. + +## Install + +1. Save the `cloudinary-1.0.0.vsix` file somewhere on your machine. +2. In VS Code: `Cmd+Shift+P` → **Extensions: Install from VSIX…** → pick the file. + Or from a terminal: `code --install-extension cloudinary-1.0.0.vsix --force` +3. Reload the window: `Cmd+Shift+P` → **Developer: Reload Window**. + +The Cloudinary icon should appear in the Activity Bar. 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..22e1db3 100644 --- a/esbuild.js +++ b/esbuild.js @@ -26,6 +26,8 @@ async function main() { "src/webview/client/upload-widget.ts", "src/webview/client/welcome.ts", "src/webview/client/homescreen.ts", + "src/webview/client/library.ts", + "src/webview/client/docs-ai.ts", ], bundle: true, format: "iife", @@ -60,7 +62,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/action-toolbar.css b/media/styles/action-toolbar.css new file mode 100644 index 0000000..382cfbd --- /dev/null +++ b/media/styles/action-toolbar.css @@ -0,0 +1,78 @@ +.cld-action-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + min-height: 40px; + border-bottom: 1px solid var(--vscode-widget-border, rgba(255, 255, 255, 0.08)); +} + +.cld-action-toolbar__group { + display: flex; + align-items: center; + gap: 2px; +} + +.cld-action-toolbar__group + .cld-action-toolbar__group { + margin-left: 6px; +} + +.cld-action-toolbar__group--utility { + margin-left: auto; +} + +.cld-action-toolbar__button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: none; + border-radius: 6px; + color: var(--vscode-icon-foreground); + cursor: pointer; + position: relative; + transition: + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.cld-action-toolbar__button svg { + display: block; + width: 16px; + height: 16px; + transition: transform 140ms ease; +} + +.cld-action-toolbar__button:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.18)); + color: var(--vscode-foreground); +} + +.cld-action-toolbar__button:hover svg { + transform: translateY(-0.5px); +} + +.cld-action-toolbar__button:active { + transform: scale(0.94); +} + +.cld-action-toolbar__button:focus-visible { + outline: 1.5px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +@media (prefers-reduced-motion: reduce) { + .cld-action-toolbar__button, + .cld-action-toolbar__button svg { + transition: none; + } + + .cld-action-toolbar__button:hover svg, + .cld-action-toolbar__button:active { + transform: none; + } +} 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/docs-ai.css b/media/styles/docs-ai.css new file mode 100644 index 0000000..3225957 --- /dev/null +++ b/media/styles/docs-ai.css @@ -0,0 +1,1236 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + height: 100%; + overflow: hidden; + font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); + font-size: 13px; + color: var(--vscode-foreground); + background: var(--vscode-sideBar-background, var(--vscode-editor-background)); +} + +.chat-shell { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Header / Tab bar */ +.header-wrapper { + flex-shrink: 0; + position: relative; + z-index: 10; +} + +.tab-bar { + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--vscode-widget-border, rgba(255,255,255,0.08)); + min-height: 32px; +} + +.tab-scroll { + flex: 1; + min-width: 0; + display: flex; + overflow-x: auto; + scrollbar-width: none; +} +.tab-scroll::-webkit-scrollbar { display: none; } + +.tab-item { + display: flex; + align-items: center; + gap: 4px; + padding: 0 6px 0 10px; + min-width: 0; + max-width: 140px; + cursor: pointer; + border-right: 1px solid var(--vscode-widget-border, rgba(255,255,255,0.06)); + white-space: nowrap; + flex-shrink: 0; + transition: background 0.1s; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} +.tab-item:hover { + background: var(--vscode-list-hoverBackground, rgba(255,255,255,0.04)); + color: var(--vscode-foreground); +} +.tab-item.active { + background: var(--vscode-editor-background, transparent); + color: var(--vscode-foreground); + border-bottom: 2px solid #4A5FD9; +} + +.tab-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.tab-close { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 2px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.12s, background 0.12s; + flex-shrink: 0; +} +.tab-item:hover .tab-close { opacity: 0.6; } +.tab-close:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground, rgba(255,255,255,0.1)); +} + +.tab-actions { + display: flex; + align-items: center; + gap: 1px; + padding: 0 4px; + flex-shrink: 0; + border-left: 1px solid var(--vscode-widget-border, rgba(255,255,255,0.06)); +} + +.tab-action-btn { + background: none; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; + transition: opacity 0.15s, background 0.15s; +} +.tab-action-btn:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground, rgba(255,255,255,0.1)); +} + +#more-btn, +#more-menu { + display: none !important; +} + +/* History dropdown */ +.history-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 70vh; + background: var(--vscode-sideBar-background, var(--vscode-editor-background)); + border-bottom: 1px solid var(--vscode-widget-border, rgba(255,255,255,0.08)); + box-shadow: 0 8px 24px rgba(0,0,0,0.35); + flex-direction: column; +} + +.history-search-row { + padding: 10px 12px 8px; + flex-shrink: 0; +} + +.history-search { + width: 100%; + background: var(--vscode-input-background, #3c3c3c); + color: var(--vscode-input-foreground, #fff); + border: 1px solid var(--vscode-input-border, rgba(255,255,255,0.1)); + border-radius: 4px; + padding: 6px 10px; + font-size: 12px; + font-family: inherit; + outline: none; +} +.history-search:focus { + border-color: #4A5FD9; +} +.history-search::placeholder { + color: var(--vscode-input-placeholderForeground, rgba(255,255,255,0.4)); +} + +.history-list { + overflow-y: auto; + flex: 1; + padding-bottom: 6px; +} + +.history-footer { + flex-shrink: 0; + margin: 0 10px 10px; + padding-top: 8px; + border-top: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.14)); +} + +.history-clear-all { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + min-height: 30px; + padding: 6px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-family: inherit; + font-size: 11.5px; + line-height: 1.2; + transition: color 0.12s ease, background 0.12s ease; +} + +.history-clear-all:hover { + color: var(--vscode-errorForeground, #f44); + background: rgba(239, 68, 68, 0.12); +} + +.history-clear-all:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.docs-ai-confirm-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.58); + opacity: 0; + transition: opacity 0.14s ease; +} + +.docs-ai-confirm-overlay.is-visible { + opacity: 1; +} + +.docs-ai-confirm-dialog { + width: min(320px, calc(100vw - 40px)); + padding: 20px; + border: 1px solid var(--vscode-editorWidget-border, rgba(255, 255, 255, 0.18)); + border-radius: 8px; + background: var(--vscode-editorWidget-background, #252526); + box-shadow: 0 20px 44px rgba(0, 0, 0, 0.42); + color: var(--vscode-foreground); + transform: translateY(4px); + transition: transform 0.14s ease; +} + +.docs-ai-confirm-overlay.is-visible .docs-ai-confirm-dialog { + transform: translateY(0); +} + +.docs-ai-confirm-title { + margin: 0 0 8px; + color: var(--vscode-foreground); + font-size: 16px; + font-weight: 700; + line-height: 1.25; +} + +.docs-ai-confirm-message { + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 13px; + line-height: 1.5; +} + +.docs-ai-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +.docs-ai-confirm-btn { + min-height: 32px; + padding: 0 14px; + border: none; + border-radius: 6px; + color: var(--vscode-button-foreground, #fff); + cursor: pointer; + font-family: inherit; + font-size: 12px; + font-weight: 600; +} + +.docs-ai-confirm-cancel { + background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.12)); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); +} + +.docs-ai-confirm-cancel:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(255, 255, 255, 0.18)); +} + +.docs-ai-confirm-delete { + background: #dc2626; + color: #fff; +} + +.docs-ai-confirm-delete:hover { + background: #ef4444; +} + +.docs-ai-confirm-btn:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.history-empty { + padding: 24px 16px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.history-group { + padding-bottom: 2px; +} + +.history-group-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); + padding: 10px 12px 4px; +} + +.history-item { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + cursor: pointer; + transition: background 0.1s; +} +.history-item:hover { + background: var(--vscode-list-hoverBackground, rgba(255,255,255,0.04)); +} +.history-item.active { + background: rgba(74, 95, 217, 0.15); +} + +.history-title { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--vscode-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-time { + font-size: 10px; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; + opacity: 0.7; +} + +.history-actions { + display: flex; + gap: 1px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.12s; +} +.history-item:hover .history-actions { opacity: 1; } +.history-item:hover .history-time { display: none; } + +.history-action { + background: none; + border: none; + color: var(--vscode-descriptionForeground); + cursor: pointer; + padding: 3px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.12s, background 0.12s; +} +.history-action:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground, rgba(255,255,255,0.1)); +} +.history-delete:hover { + color: var(--vscode-errorForeground, #f44); +} + +.history-rename-input { + flex: 1; + min-width: 0; + background: var(--vscode-input-background, #3c3c3c); + color: var(--vscode-input-foreground, #fff); + border: 1px solid #4A5FD9; + border-radius: 3px; + padding: 3px 6px; + font-size: 12px; + font-family: inherit; + outline: none; +} + +/* Conversation */ +#conversation { + flex: 1; + overflow-y: auto; + padding: 10px 10px 10px; +} + +#conversation.empty-state { + display: flex; + flex-direction: column; +} + +.empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 16px; + text-align: center; + color: var(--vscode-descriptionForeground); + gap: 20px; +} + +.empty-heading { + font-size: 18px; + font-weight: 600; + color: var(--vscode-foreground); + letter-spacing: -0.01em; +} + +.empty-sub { + font-size: 12.5px; + color: var(--vscode-descriptionForeground); + margin-top: -8px; +} + +.chips { display: flex; flex-direction: column; gap: 6px; width: 100%; align-items: center; } + +.chip { + background: #4A5FD9; + color: #fff; + border: none; + border-radius: 8px; + padding: 8px 12px; + font-size: 12px; + cursor: pointer; + text-align: left; + transition: opacity 0.15s; +} +.chip:hover { + opacity: 0.85; +} + +/* Recent conversations in empty state */ +.recent-chats { + flex-shrink: 0; + padding: 12px 16px 16px; + border-top: 1px solid var(--vscode-widget-border, rgba(255,255,255,0.08)); +} +.recent-heading { + font-size: 11px; + font-weight: 500; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} +.recent-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + background: none; + border: none; + border-radius: 6px; + color: var(--vscode-foreground); + font-size: 12px; + cursor: pointer; + text-align: left; + transition: background 0.1s; +} +.recent-item:hover { + background: var(--vscode-list-hoverBackground, rgba(255,255,255,0.04)); +} +.recent-icon { + flex-shrink: 0; + opacity: 0.5; +} +.recent-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.recent-time { + flex-shrink: 0; + font-size: 10px; + color: var(--vscode-descriptionForeground); +} + +/* Empty state centered input */ +.empty-input-row { + position: relative; + width: 100%; + margin-top: 4px; +} + +.empty-input { + width: 100%; + background: var(--vscode-input-background, #3c3c3c); + color: var(--vscode-input-foreground, #fff); + border: 1px solid var(--vscode-input-border, rgba(255,255,255,0.15)); + border-radius: 12px; + padding: 14px 46px 40px 14px; + font-size: 13.5px; + font-family: inherit; + outline: none; + resize: none; + overflow-y: auto; + min-height: 100px; + max-height: 200px; + line-height: 1.5; +} +.empty-input:focus { + border-color: #4A5FD9; +} +.empty-input::placeholder { + color: var(--vscode-input-placeholderForeground, rgba(255,255,255,0.4)); +} + +.empty-send-btn { + position: absolute; + right: 8px; + bottom: 10px; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + background: #4A5FD9; + color: #fff; + transition: opacity 0.15s; +} +.empty-send-btn:hover { opacity: 0.85; } +.empty-send-btn:disabled { opacity: 0.3; cursor: default; } + +/* Messages */ +.message { margin-bottom: 14px; } + +.message.user-message { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.message-meta { + margin-bottom: 5px; + padding-left: 2px; +} + +.role-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); +} + +.message-meta.right { + text-align: right; + padding-right: 2px; + padding-left: 0; +} + +.msg-actions { + margin-top: 2px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + padding-right: 2px; + opacity: 0; + transition: opacity 0.15s; +} + +.message:hover .msg-actions { + opacity: 1; +} + +.msg-actions-group { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.msg-actions-sep { + width: 1px; + height: 14px; + background: var(--vscode-widget-border, rgba(255,255,255,0.12)); + margin: 0 4px; +} + +.msg-copy, .msg-feedback { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--vscode-descriptionForeground); + display: inline-flex; + align-items: center; + border-radius: 4px; + transition: color 0.15s, background 0.15s; +} + +.msg-copy:hover, .msg-feedback:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(255,255,255,0.1)); + color: var(--vscode-foreground); +} + +.msg-copy.copied { + color: var(--vscode-terminal-ansiGreen, #4ec9b0); +} + +.msg-feedback-up.selected { + color: var(--vscode-terminal-ansiGreen, #4ec9b0); +} + +.msg-feedback-down.selected { + color: var(--vscode-errorForeground, #f44); +} + +/* Feedback form (downvote) */ +.feedback-form { + margin-top: 6px; + animation: feedbackSlideIn 0.15s ease-out; +} + +@keyframes feedbackSlideIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.feedback-form-inner { + background: var(--vscode-editorWidget-background, #252526); + border: 1px solid var(--vscode-widget-border, rgba(255,255,255,0.1)); + border-radius: 8px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.feedback-form-header { + font-size: 12px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.feedback-stars { + display: flex; + gap: 2px; +} + +.feedback-star { + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: var(--vscode-descriptionForeground); + display: flex; + align-items: center; + border-radius: 3px; + transition: color 0.12s, transform 0.12s; +} + +.feedback-star:hover { + transform: scale(1.15); +} + +.feedback-star.active { + color: #e8a317; +} + +.feedback-comment { + width: 100%; + min-height: 56px; + max-height: 120px; + padding: 8px 10px; + background: var(--vscode-input-background, #3c3c3c); + color: var(--vscode-input-foreground, #fff); + border: 1px solid var(--vscode-input-border, rgba(255,255,255,0.1)); + border-radius: 6px; + font-size: 12px; + font-family: inherit; + line-height: 1.5; + resize: vertical; + outline: none; +} + +.feedback-comment:focus { + border-color: #4A5FD9; +} + +.feedback-comment::placeholder { + color: var(--vscode-input-placeholderForeground, rgba(255,255,255,0.4)); +} + +.feedback-form-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.feedback-cancel-btn, +.feedback-submit-btn { + padding: 5px 12px; + border-radius: 5px; + font-size: 12px; + font-family: inherit; + cursor: pointer; + border: none; + transition: background 0.12s; +} + +.feedback-cancel-btn { + background: rgba(255,255,255,0.08); + color: var(--vscode-foreground); +} +.feedback-cancel-btn:hover { + background: rgba(255,255,255,0.12); +} + +.feedback-submit-btn { + background: #4A5FD9; + color: #fff; +} +.feedback-submit-btn:hover { + opacity: 0.9; +} + +.feedback-thankyou { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + color: var(--vscode-terminal-ansiGreen, #4ec9b0); + font-size: 12px; + font-weight: 500; +} + +.bubble.user { + cursor: pointer; +} + +.bubble.user.editing { + cursor: default; + background: var(--vscode-input-background, #3c3c3c) !important; + color: var(--vscode-input-foreground, #fff) !important; + border: 1px solid #4A5FD9; + width: 100%; + box-sizing: border-box; +} + +.edit-input-row { + display: flex; + align-items: flex-end; + gap: 6px; +} + +.edit-textarea { + flex: 1; + padding: 0; + margin: 0; + border: none; + outline: none; + background: transparent; + color: inherit; + font-family: inherit; + font-size: inherit; + line-height: 1.55; + resize: none; + box-sizing: border-box; + overflow: hidden; +} + +.edit-send-btn { + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + background: #4A5FD9; + color: #fff; + transition: opacity 0.15s; +} +.edit-send-btn:hover { opacity: 0.85; } +.edit-send-btn:disabled { opacity: 0.3; cursor: default; } + + +.bubble { + padding: 8px 12px; + border-radius: 10px; + line-height: 1.55; + word-break: break-word; +} + +.bubble.user { + background: #4A5FD9; + color: #fff; + border-radius: 10px 10px 2px 10px; + display: inline-block; + max-width: 85%; +} +.bubble.user[dir="rtl"], .bubble.user:dir(rtl) { + border-radius: 10px 10px 10px 2px; +} + +.bubble.assistant { + background: var(--vscode-editorWidget-background, rgba(255,255,255,0.05)); + border: 1px solid var(--vscode-widget-border, rgba(255,255,255,0.08)); + border-radius: 10px 10px 10px 2px; +} + +.bubble.error { + border-color: var(--vscode-errorForeground, #f44); + color: var(--vscode-errorForeground, #f44); +} + +.bubble p { margin: 0.15rem 0; } + +.muted { + opacity: 0.5; + font-style: italic; +} + +.cursor { + animation: blink 0.8s steps(2) infinite; + color: var(--vscode-editorCursor-foreground, #fff); + font-weight: 700; + margin-left: 2px; +} +@keyframes blink { 50% { opacity: 0; } } + +/* Markdown content */ +.md { line-height: 1.6; } +.md h1 { font-size: 1.3rem; font-weight: 700; margin: 0.5rem 0 0.2rem; } +.md h2 { font-size: 1.15rem; font-weight: 600; margin: 0.4rem 0 0.15rem; } +.md h3 { font-size: 1.05rem; font-weight: 600; margin: 0.3rem 0 0.1rem; } +.md p { margin: 0.2rem 0; } +.md ul, .md ol { margin: 0.2rem 0; padding-inline-start: 1.4rem; } +.md li { margin: 0.05rem 0; } +.md ul li { list-style: disc; } +.md ol li { list-style: decimal; } +.md strong { font-weight: 700; color: var(--vscode-foreground); } +.md em { font-style: italic; } +.md a { + color: #4A5FD9; + text-decoration: none; +} +.md a:hover { text-decoration: underline; } + +.md code:not(.hljs) { + background: var(--vscode-textCodeBlock-background, rgba(255,255,255,0.1)); + color: var(--vscode-textPreformat-foreground, #d7ba7d); + padding: 1px 5px; + border-radius: 4px; + font-family: var(--vscode-editor-font-family, 'Menlo', monospace); + font-size: 0.9em; +} + +/* Code block container */ +.code-block { + position: relative; + margin: 0.4rem 0; + border-radius: 6px; + border: 1px solid var(--vscode-widget-border, rgba(255,255,255,0.08)); + overflow: hidden; +} + +.code-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px 4px 12px; + background: var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editorWidget-background)); + border-bottom: 1px solid var(--vscode-widget-border, rgba(128,128,128,0.2)); + min-height: 28px; +} + +.code-lang { + font-size: 11px; + color: var(--vscode-descriptionForeground, rgba(255,255,255,0.5)); + font-family: inherit; + text-transform: lowercase; +} + +.code-copy { + display: inline-flex; + align-items: center; + gap: 4px; + background: none; + border: none; + color: var(--vscode-descriptionForeground, rgba(255,255,255,0.5)); + cursor: pointer; + font-size: 11px; + font-family: inherit; + padding: 2px 6px; + border-radius: 3px; + transition: color 0.15s, background 0.15s; +} +.code-copy:hover { + color: var(--vscode-foreground); + background: var(--vscode-toolbar-hoverBackground, rgba(255,255,255,0.1)); +} +.code-copy.copied { + color: var(--vscode-terminal-ansiGreen, #4ec9b0); +} + +.code-block pre { + margin: 0; + border: none; + border-radius: 0; +} + +.md pre { + background: var(--vscode-textCodeBlock-background, #1e1e1e); + padding: 10px 12px; + margin: 0.3rem 0; + overflow-x: auto; + font-family: var(--vscode-editor-font-family, 'Menlo', monospace); +} + +.code-block .md pre, +.code-block pre { + margin: 0; + border-radius: 0; +} + +.md pre code { + background: none; + color: inherit; + padding: 0; + font-size: 12px; + line-height: 1.5; +} + +/* Highlight.js theme — uses VS Code token colors so it adapts to any theme */ +.hljs { color: var(--vscode-editor-foreground); } +.hljs-keyword, +.hljs-literal, +.hljs-symbol, +.hljs-meta { color: var(--vscode-symbolIcon-keywordForeground, var(--vscode-debugTokenExpression-name)); } +.hljs-built_in, +.hljs-type, +.hljs-title.class_ { color: var(--vscode-symbolIcon-classForeground, var(--vscode-debugTokenExpression-name)); } +.hljs-number { color: var(--vscode-debugTokenExpression-number, var(--vscode-editor-foreground)); } +.hljs-string, +.hljs-regexp, +.hljs-link { color: var(--vscode-debugTokenExpression-string, var(--vscode-editor-foreground)); } +.hljs-variable, +.hljs-template-variable, +.hljs-selector-id, +.hljs-attr, +.hljs-attribute, +.hljs-params, +.hljs-property { color: var(--vscode-symbolIcon-variableForeground, var(--vscode-editor-foreground)); } +.hljs-comment, +.hljs-quote, +.hljs-doctag { color: var(--vscode-descriptionForeground); font-style: italic; } +.hljs-function, +.hljs-title, +.hljs-title.function_, +.hljs-section { color: var(--vscode-symbolIcon-functionForeground, var(--vscode-textLink-foreground)); } +.hljs-tag, +.hljs-name { color: var(--vscode-symbolIcon-keywordForeground, var(--vscode-editor-foreground)); } +.hljs-selector-tag, +.hljs-selector-class, +.hljs-bullet { color: var(--vscode-symbolIcon-propertyForeground, var(--vscode-editor-foreground)); } +.hljs-addition { color: var(--vscode-gitDecoration-addedResourceForeground, green); } +.hljs-deletion { color: var(--vscode-gitDecoration-deletedResourceForeground, red); } +.hljs-emphasis { font-style: italic; } +.hljs-strong { font-weight: bold; } +.hljs-punctuation, +.hljs-operator { color: var(--vscode-editor-foreground); } + +.md blockquote { + border-inline-start: 3px solid var(--vscode-textBlockQuote-border, rgba(255,255,255,0.2)); + padding: 4px 10px; + margin: 0.3rem 0; + opacity: 0.85; + font-style: italic; +} + +.md table { width: 100%; border-collapse: collapse; margin: 0.3rem 0; font-size: 12px; } +.md th, .md td { + padding: 6px 8px; + text-align: start; + border-bottom: 1px solid var(--vscode-widget-border, rgba(255,255,255,0.08)); +} +.md th { font-weight: 600; } + +.md hr { border: none; height: 1px; background: var(--vscode-widget-border); margin: 0.4rem 0; } + +.md .doc-citation { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + margin: 0 1px; + padding: 0 5px; + border-radius: 999px; + background: color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 18%, transparent); + color: var(--vscode-textLink-foreground, #3794ff); + font-size: 0.78em; + font-weight: 700; + line-height: 1; + text-decoration: none; + vertical-align: baseline; + white-space: nowrap; +} + +.md .doc-citation:hover { + background: color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 28%, transparent); + text-decoration: none; +} + +/* Sources */ +.sources { margin-top: 4px; } + +.sources-toggle { + background: none; + border: none; + color: var(--vscode-textLink-foreground, #3794ff); + cursor: pointer; + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px 0; +} +.sources-toggle:hover { text-decoration: underline; } + +.chevron { + display: inline-flex; + align-items: center; + transition: transform 0.2s; + font-size: 14px; + line-height: 1; +} +.chevron-open { transform: rotate(180deg); } + +.sources-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.source-item { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--vscode-textLink-foreground, #3794ff); + text-decoration: none; + padding: 3px 6px 3px 4px; + border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 24%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 10%, transparent); + white-space: nowrap; +} +.source-item:hover { + background: color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 16%, transparent); + text-decoration: none; +} +.source-item svg { flex-shrink: 0; opacity: 0.6; } + +.source-index { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: var(--vscode-textLink-foreground, #3794ff); + color: var(--vscode-button-foreground, #fff); + font-size: 10px; + font-weight: 700; + line-height: 1; +} + +.source-title { + overflow: hidden; + max-width: 220px; + text-overflow: ellipsis; +} + +/* Follow-ups */ +.follow-ups { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + margin-top: 8px; +} + +.follow-up-btn { + background: #4A5FD9; + color: #fff; + border: none; + border-radius: 6px; + padding: 6px 10px; + font-size: 12px; + cursor: pointer; + text-align: left; + transition: opacity 0.15s; +} +.follow-up-btn:hover { + opacity: 0.85; +} + +/* Composer */ +.composer { + flex-shrink: 0; + padding: 10px 10px 12px; + border-top: 1px solid var(--vscode-widget-border, rgba(255,255,255,0.08)); +} + +.input-row { + position: relative; +} + +#chat-input { + width: 100%; + background: var(--vscode-input-background, #3c3c3c); + color: var(--vscode-input-foreground, #fff); + border: 1px solid var(--vscode-input-border, rgba(255,255,255,0.1)); + border-radius: 10px; + padding: 10px 42px 10px 12px; + font-size: 13px; + font-family: inherit; + outline: none; + resize: none; + overflow-y: auto; + min-height: 44px; + max-height: 150px; + line-height: 1.45; +} +#chat-input:focus { + border-color: #4A5FD9; +} +#chat-input::placeholder { + color: var(--vscode-input-placeholderForeground, rgba(255,255,255,0.4)); +} + +#send-btn, #stop-btn { + position: absolute; + right: 7px; + bottom: 8px; + width: 30px; + height: 30px; + padding: 0; + border: none; + border-radius: 7px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.15s, background 0.15s; +} + +#send-btn { + background: #4A5FD9; + color: #fff; +} +#send-btn:hover { opacity: 0.85; } +#send-btn:disabled { opacity: 0.3; cursor: default; } + +#stop-btn { + background: rgba(255,255,255,0.15); + color: var(--vscode-descriptionForeground, #999); + border: 1px solid rgba(255,255,255,0.15); + width: 26px; + height: 26px; + display: none; +} +#stop-btn svg { width: 10px; height: 10px; } +#stop-btn:hover { background: rgba(255,255,255,0.25); } + +#disclaimer { + font-size: 11px; + color: var(--vscode-descriptionForeground); + text-align: center; + margin-top: 6px; +} + +#disclaimer a { + color: #4A5FD9; + text-decoration: none; +} +#disclaimer a:hover { text-decoration: underline; } + +/* Undo Toast */ +.undo-toast { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--vscode-editorWidget-background, #1e1e1e); + border: 1px solid var(--vscode-editorWidget-border, rgba(255,255,255,0.12)); + color: var(--vscode-foreground, #ccc); + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + display: flex; + align-items: center; + gap: 12px; + opacity: 0; + transition: opacity 0.2s, transform 0.2s; + z-index: 1000; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); +} +.undo-toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); } +.undo-btn { + background: none; + border: none; + color: #4A5FD9; + font-size: 13px; + font-weight: 600; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; +} +.undo-btn:hover { background: rgba(74, 95, 217, 0.15); } diff --git a/media/styles/homescreen.css b/media/styles/homescreen.css index c0eb507..9409600 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,11 +297,48 @@ 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); } +.hs-search-loading { + color: var(--vscode-progressBar-background, var(--vscode-focusBorder)); + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.hs-search-spinner { + width: 12px; + height: 12px; + border: 2px solid rgba(127, 127, 127, 0.35); + border-top-color: currentColor; + border-radius: 50%; + animation: hs-search-spin 720ms linear infinite; +} + +.hs-search--loading .hs-search-clear { + display: none; +} + +@keyframes hs-search-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .hs-search-spinner { + animation: none; + } +} + /* ── Actions ── */ .hs-actions { padding: 8px; @@ -265,32 +364,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 +428,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 +447,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 +458,403 @@ 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)); +} + +/* ── Docs AI home launcher ── */ +.hs-docs-ai-home { + margin: 10px 0 0; + padding: 18px 0 4px; + border-top: 1px solid var(--vscode-panel-border, rgba(128,128,128,0.14)); +} + +.hs-docs-ai-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 18px 0 0; + text-align: center; + color: var(--vscode-descriptionForeground); +} + +.hs-docs-ai-empty-heading { + margin: 0; + font-size: 18px; + font-weight: 600; + line-height: 1.2; + color: var(--vscode-foreground); + letter-spacing: 0; +} + +.hs-docs-ai-history-toggle-wrap { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + margin-top: -4px; +} + +.hs-docs-ai-history-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 28px; + padding: 5px 10px; + border: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.18)); + border-radius: 6px; + background: transparent; + color: var(--vscode-textLink-foreground); + cursor: pointer; + font-family: inherit; + font-size: 11.5px; + line-height: 1.2; +} + +.hs-docs-ai-history-toggle:hover, +.hs-docs-ai-history-toggle.is-active { + background: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.14)); + color: var(--vscode-textLink-foreground); +} + +.hs-docs-ai-history-toggle:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.hs-docs-ai-empty-sub { + margin: -8px 0 0; + font-size: 12.5px; + line-height: 1.45; + color: var(--vscode-descriptionForeground); +} + +.hs-docs-ai-empty-input-row { + position: relative; + width: 100%; + margin-top: 4px; +} + +.hs-docs-ai-empty-input { + width: 100%; + min-height: 100px; + max-height: 200px; + padding: 14px 46px 40px 14px; + overflow-y: auto; + resize: none; + background: var(--vscode-input-background, #3c3c3c); + border: 1px solid var(--vscode-input-border, rgba(255,255,255,0.15)); + border-radius: 12px; + outline: none; + color: var(--vscode-input-foreground, #fff); + font-family: inherit; + font-size: 13.5px; + line-height: 1.5; +} + +.hs-docs-ai-empty-input:focus { + border-color: #4A5FD9; +} + +.hs-docs-ai-empty-input::placeholder { + color: var(--vscode-input-placeholderForeground, rgba(255,255,255,0.4)); +} + +.hs-docs-ai-empty-send-btn { + position: absolute; + right: 8px; + bottom: 10px; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 8px; + background: #4A5FD9; + cursor: pointer; + color: #fff; + transition: opacity 0.15s; +} + +.hs-docs-ai-empty-send-btn:hover { opacity: 0.85; } +.hs-docs-ai-empty-send-btn:disabled { opacity: 0.3; cursor: default; } + +.hs-docs-ai-chips { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + width: 100%; + overflow-x: auto; + overflow-y: hidden; + padding: 0 0 2px; + scroll-snap-type: x proximity; + scrollbar-width: none; +} + +.hs-docs-ai-chips::-webkit-scrollbar { + display: none; +} + +.hs-docs-ai-chip { + flex: 0 0 auto; + width: max-content; + max-width: none; + padding: 8px 12px; + border: none; + border-radius: 8px; + background: #4A5FD9; + color: #fff; + cursor: pointer; + font-family: inherit; + font-size: 12px; + line-height: 1.25; + text-align: left; + white-space: nowrap; + scroll-snap-align: start; + transition: opacity 0.15s; +} + +.hs-docs-ai-chip:hover { + opacity: 0.85; +} + +.hs-docs-ai-history-panel { + width: 100%; + margin-top: -2px; +} + +.hs-docs-ai-recent-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.hs-docs-ai-recent-list--expanded { + max-height: 260px; + overflow-y: auto; + padding-right: 2px; +} + +.hs-docs-ai-history-group + .hs-docs-ai-history-group { + margin-top: 8px; +} + +.hs-docs-ai-history-group-label { + margin: 0 0 3px; + padding: 0 2px; + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + opacity: 0.8; +} + +.hs-docs-ai-history-empty { + padding: 8px 2px; + color: var(--vscode-descriptionForeground); + font-size: 11.5px; + text-align: left; +} + +.hs-docs-ai-history-footer { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.14)); +} + +.hs-docs-ai-clear-all { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + min-height: 30px; + padding: 6px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-family: inherit; + font-size: 11.5px; + line-height: 1.2; + transition: color 0.12s ease, background 0.12s ease; +} + +.hs-docs-ai-clear-all:hover { + color: var(--vscode-errorForeground, #f44); + background: rgba(239, 68, 68, 0.12); +} + +.hs-docs-ai-clear-all:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.docs-ai-confirm-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.58); + opacity: 0; + transition: opacity 0.14s ease; +} + +.docs-ai-confirm-overlay.is-visible { + opacity: 1; +} + +.docs-ai-confirm-dialog { + width: min(320px, calc(100vw - 40px)); + padding: 20px; + border: 1px solid var(--vscode-editorWidget-border, rgba(255, 255, 255, 0.18)); + border-radius: 8px; + background: var(--vscode-editorWidget-background, #252526); + box-shadow: 0 20px 44px rgba(0, 0, 0, 0.42); + color: var(--vscode-foreground); + transform: translateY(4px); + transition: transform 0.14s ease; +} + +.docs-ai-confirm-overlay.is-visible .docs-ai-confirm-dialog { + transform: translateY(0); +} + +.docs-ai-confirm-title { + margin: 0 0 8px; + color: var(--vscode-foreground); + font-size: 16px; + font-weight: 700; + line-height: 1.25; +} + +.docs-ai-confirm-message { + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 13px; + line-height: 1.5; +} + +.docs-ai-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +.docs-ai-confirm-btn { + min-height: 32px; + padding: 0 14px; + border: none; + border-radius: 6px; + color: var(--vscode-button-foreground, #fff); + cursor: pointer; + font-family: inherit; + font-size: 12px; + font-weight: 600; +} + +.docs-ai-confirm-cancel { + background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.12)); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); +} + +.docs-ai-confirm-cancel:hover { + background: var(--vscode-button-secondaryHoverBackground, rgba(255, 255, 255, 0.18)); +} + +.docs-ai-confirm-delete { + background: #dc2626; + color: #fff; +} + +.docs-ai-confirm-delete:hover { + background: #ef4444; +} + +.docs-ai-confirm-btn:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +.hs-docs-ai-recent-item { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + min-height: 32px; + padding: 7px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + font-family: inherit; + text-align: left; + transition: background 0.12s ease; +} + +.hs-docs-ai-recent-item:hover { + background: var(--vscode-list-hoverBackground); +} + +.hs-docs-ai-recent-item:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.hs-docs-ai-recent-icon { + flex-shrink: 0; + color: var(--vscode-descriptionForeground); + opacity: 0.65; +} + +.hs-docs-ai-recent-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11.5px; + line-height: 1.25; +} + +.hs-docs-ai-recent-time { + flex-shrink: 0; + font-size: 9.5px; + color: var(--vscode-descriptionForeground); +} + +.hs-docs-ai-empty-send-btn:focus-visible, +.hs-docs-ai-chip:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; } /* ── 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 +868,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 +905,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 +954,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 +979,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 +998,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 +1034,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 +1084,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 +1103,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 +1154,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 +1198,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 +1207,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 +1231,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 +1252,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 +1293,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 +1326,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; +} diff --git a/media/styles/library.css b/media/styles/library.css new file mode 100644 index 0000000..83a76b3 --- /dev/null +++ b/media/styles/library.css @@ -0,0 +1,833 @@ +/** + * 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 + ======================================== */ +.lib-header { + flex: 0 0 auto; + background: var(--lib-header-bg); + border-bottom: 1px solid var(--lib-header-border); +} + +/* ======================================== + 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 { + position: relative; + 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; + overflow: hidden; +} + +.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); +} + +.lib-search__loading { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--vscode-progressBar-background, var(--lib-accent)); +} + +.lib-search__spinner { + width: 12px; + height: 12px; + border: 2px solid rgba(127, 127, 127, 0.35); + border-top-color: currentColor; + border-radius: 50%; + animation: libSearchSpin 720ms linear infinite; +} + +.lib-search--loading .lib-search__clear { + display: none; +} + +@keyframes libSearchSpin { + to { transform: rotate(360deg); } +} + +/* ======================================== + 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-row--search-loading { + color: var(--vscode-descriptionForeground); + font-style: normal; + background: transparent; +} + +.lib-row--search-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-twistie svg, + .lib-row::before, + .lib-loader-dots i, + .lib-error, + .lib-error__dot, + .lib-search, + .lib-search__wrap, + .lib-search__spinner, + .lib-filter, + .lib-filter__group, + .lib-menu, + .lib-menu-item, + .lib-menu-item__icon, + .lib-clear-chip, + .lib-empty__cta { + transition: none; + animation: none; + } +} diff --git a/package-lock.json b/package-lock.json index 507d030..faead14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cloudinary", - "version": "0.1.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cloudinary", - "version": "0.1.2", + "version": "1.0.0", "license": "MIT", "dependencies": { "cloudinary": "^2.5.1" @@ -21,6 +21,8 @@ "esbuild": "^0.25.9", "eslint": "^8.52.0", "glob": "^10.3.10", + "highlight.js": "^11.11.1", + "marked": "^17.0.3", "mocha": "^10.2.0", "npm-run-all": "^4.1.5", "typescript": "^5.2.2" @@ -738,6 +740,7 @@ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", @@ -993,6 +996,7 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1952,6 +1956,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2619,6 +2624,16 @@ "he": "bin/he" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -3400,6 +3415,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/marked": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", + "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5242,6 +5270,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index d1e3ff7..4212e45 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publisher": "Cloudinary", "displayName": "Cloudinary", "description": "Easily find and manage your Cloudinary assets directly from Visual Studio Code.", - "version": "0.1.4", + "version": "1.0.0", "icon": "resources/cloudinary_icon_blue.png", "repository": { "type": "git", @@ -53,15 +53,35 @@ "id": "cloudinaryHomescreen", "name": "Cloudinary", "type": "webview", - "when": "cloudinary.activeView != 'library'" + "when": "cloudinary.activeView != 'library' && cloudinary.activeView != 'docsAi'" }, { "id": "cloudinaryMediaLibrary", - "name": "", + "name": "Library", + "type": "webview", + "contextualTitle": "Cloudinary Library", "when": "cloudinary.activeView == 'library'" + }, + { + "id": "cloudinaryDocsAI", + "name": "Docs AI", + "type": "webview", + "contextualTitle": "Cloudinary Docs AI", + "when": "cloudinary.activeView == 'docsAi'" } ] }, + "configuration": { + "title": "Cloudinary", + "properties": { + "cloudinary.docsAi.apiBase": { + "type": "string", + "default": "https://cld-docs-ai-delta.vercel.app", + "scope": "machine-overridable", + "markdownDescription": "Base URL for the Cloudinary Docs AI backend. Override this for local development, for example `http://127.0.0.1:5173`." + } + } + }, "commands": [ { "command": "cloudinary.openAsset", @@ -131,65 +151,29 @@ "title": "Browse Media Library", "category": "Cloudinary" }, + { + "command": "cloudinary.showDocsAI", + "title": "Open Docs AI", + "category": "Cloudinary" + }, { "command": "cloudinary.configureAiTools", "title": "Configure AI Tools", "category": "Cloudinary" + }, + { + "command": "cloudinary.docsAi.refresh", + "title": "Refresh Docs AI", + "icon": "$(refresh)", + "category": "Cloudinary" + }, + { + "command": "cloudinary.refreshFolderMode", + "title": "Refresh Folder Mode", + "icon": "$(refresh)", + "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", @@ -215,6 +199,8 @@ "esbuild": "^0.25.9", "eslint": "^8.52.0", "glob": "^10.3.10", + "highlight.js": "^11.11.1", + "marked": "^17.0.3", "mocha": "^10.2.0", "npm-run-all": "^4.1.5", "typescript": "^5.2.2" 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/analytics/analyticsService.ts b/src/analytics/analyticsService.ts new file mode 100644 index 0000000..4c075f3 --- /dev/null +++ b/src/analytics/analyticsService.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "crypto"; + +export type AnalyticsValue = + | string + | number + | boolean + | null + | undefined; + +export type AnalyticsPayload = Record; + +type AnalyticsStorage = { + get(key: string, defaultValue: T): T; + update(key: string, value: unknown): Thenable; +}; + +type AnalyticsServiceOptions = { + extensionVersion: string; + storage: AnalyticsStorage; + getCloudName?: () => string | null | undefined; + getDebugId?: () => string | null | undefined; + getIdePlatform?: () => string; + fetchFn?: (url: string, init: { method: "POST" }) => Promise; + now?: () => Date; + createSessionId?: () => string; +}; + +const ANALYTICS_ENDPOINT = "https://analytics-api.cloudinary.com"; +const ANALYTICS_FEATURE = "vscode_extension"; +// Parentheses and spaces are invalid in Cloudinary cloud names, so this +// sentinel can never collide with a real configured cloud. +const NO_CLOUD_CONFIGURED = "(not configured)"; +const SESSION_STORAGE_KEY = "cloudinary.analyticsSessionId"; +const SENSITIVE_PAYLOAD_KEYS = new Set([ + "apikey", + "api_key", + "apisecret", + "api_secret", + "accesstoken", + "access_token", + "authorization", + "clientsecret", + "client_secret", + "email", + "idtoken", + "id_token", + "message", + "message_content", + "password", + "phone", + "prompt", + "refreshtoken", + "refresh_token", + "secret", + "token", + "useremail", + "user_email", + "user_message", +]); +const RESERVED_PAYLOAD_KEYS = new Set([ + "source", + "extension_version", + "ide_platform", + "session_id", + "event_time", + "event", +]); + +function isSafeEventName(eventName: string): boolean { + return /^[a-z][a-z0-9_]*$/.test(eventName); +} + +function normalizePayloadKey(key: string): string { + return key.trim().replace(/[-.\s]/g, "_").toLowerCase(); +} + +function normalizePayloadValue(value: unknown): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || undefined; + } + + if (typeof value === "number") { + return Number.isFinite(value) ? String(value) : undefined; + } + + if (typeof value === "boolean") { + return String(value); + } + + return undefined; +} + +function isSensitivePayloadKey(key: string): boolean { + const normalizedKey = normalizePayloadKey(key); + return SENSITIVE_PAYLOAD_KEYS.has(normalizedKey); +} + +function isReservedPayloadKey(key: string): boolean { + return RESERVED_PAYLOAD_KEYS.has(normalizePayloadKey(key)); +} + +export class AnalyticsService { + private readonly extensionVersion: string; + private readonly storage: AnalyticsStorage; + private readonly getCloudName: () => string | null | undefined; + private readonly getDebugId: () => string | null | undefined; + private readonly getIdePlatform: () => string; + private readonly fetchFn: (url: string, init: { method: "POST" }) => Promise; + private readonly now: () => Date; + private readonly createSessionId: () => string; + + constructor(options: AnalyticsServiceOptions) { + this.extensionVersion = options.extensionVersion; + this.storage = options.storage; + this.getCloudName = options.getCloudName ?? (() => undefined); + this.getDebugId = options.getDebugId ?? (() => undefined); + this.getIdePlatform = options.getIdePlatform ?? (() => "unknown"); + this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init)); + this.now = options.now ?? (() => new Date()); + this.createSessionId = options.createSessionId ?? randomUUID; + } + + track(eventName: string, payload: AnalyticsPayload = {}): void { + void this.send(eventName, payload); + } + + async send(eventName: string, payload: AnalyticsPayload = {}): Promise { + if (!isSafeEventName(eventName)) { + return; + } + + const sessionId = await this.getSessionId(); + const cloudName = normalizePayloadValue(this.getCloudName()); + const debugId = normalizePayloadValue(this.getDebugId()); + const params = new URLSearchParams({ + source: ANALYTICS_FEATURE, + event: eventName, + extension_version: this.extensionVersion, + ide_platform: this.getIdePlatform(), + session_id: sessionId, + event_time: this.now().toISOString(), + }); + + // Always attribute the event to the active cloud, falling back to an + // explicit sentinel so "no cloud configured" is distinguishable from a + // dropped param downstream. + params.set("cloud_name", cloudName ?? NO_CLOUD_CONFIGURED); + if (debugId) { + params.set("debug_id", debugId); + } + + for (const [key, value] of Object.entries(payload)) { + if (!key || isReservedPayloadKey(key) || isSensitivePayloadKey(key)) { + continue; + } + + const normalized = normalizePayloadValue(value); + if (normalized !== undefined) { + params.set(key, normalized); + } + } + + const url = `${ANALYTICS_ENDPOINT}/${ANALYTICS_FEATURE}?${params.toString()}`; + + try { + await this.fetchFn(url, { method: "POST" }); + } catch { + // Analytics must never interrupt extension workflows. + } + } + + private async getSessionId(): Promise { + const existing = this.storage.get(SESSION_STORAGE_KEY, ""); + if (existing) { + return existing; + } + + const created = this.createSessionId(); + await this.storage.update(SESSION_STORAGE_KEY, created); + return created; + } +} diff --git a/src/analytics/trackConfigValidation.ts b/src/analytics/trackConfigValidation.ts new file mode 100644 index 0000000..0d16558 --- /dev/null +++ b/src/analytics/trackConfigValidation.ts @@ -0,0 +1,39 @@ +import { AnalyticsService } from "./analyticsService"; +import type { FolderModeResult } from "../config/detectFolderMode"; + +/** + * Emits a config validation analytics event based on a folder-mode detection result. + * Fires `config_validation_succeeded` when the credentials were accepted, or + * `config_validation_failed` when they were rejected or unreachable. `skipped` + * outcomes (missing or placeholder credentials) emit nothing. + * + * The payload never includes credentials; only the active cloud name (attached by + * the analytics service), the folder mode, and a coarse error reason are reported. + * + * @param analytics - The analytics service, if available. + * @param result - The folder-mode detection result. + * @param entryPoint - Where validation was triggered (activation, config_change, switch). + */ +export function trackConfigValidation( + analytics: AnalyticsService | undefined, + result: FolderModeResult, + entryPoint: string +): void { + if (!analytics || result.outcome === "skipped") { + return; + } + + if (result.outcome === "success") { + analytics.track("config_validation_succeeded", { + folder_mode: result.dynamicFolders ? "dynamic" : "fixed", + entry_point: entryPoint, + }); + return; + } + + analytics.track("config_validation_failed", { + error_reason: result.errorReason, + status: result.status, + entry_point: entryPoint, + }); +} 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..709edc3 --- /dev/null +++ b/src/cloudinary/cloudinaryService.ts @@ -0,0 +1,297 @@ +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[] = []; + /** + * Whether the configured credentials have been confirmed valid against the API. + * `true` = validated OK, `false` = rejected (e.g. 401/403), `undefined` = not + * yet checked. Drives the "Connected" vs "Setup needed" status, so it must not + * read `true` purely because credential fields are non-empty. + */ + credentialsValid: boolean | undefined = undefined; + + 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/copyCommands.ts b/src/commands/copyCommands.ts index edafe8d..ef7b862 100644 --- a/src/commands/copyCommands.ts +++ b/src/commands/copyCommands.ts @@ -1,10 +1,11 @@ import * as vscode from "vscode"; +import { AnalyticsService } from "../analytics/analyticsService"; /** * Registers clipboard-related commands for copying asset details from Cloudinary. * @param context - The VS Code extension context. */ -function registerClipboard(context: vscode.ExtensionContext) { +function registerClipboard(context: vscode.ExtensionContext, analytics?: AnalyticsService) { context.subscriptions.push( vscode.commands.registerCommand( "cloudinary.copyUrl", @@ -15,6 +16,7 @@ function registerClipboard(context: vscode.ExtensionContext) { return; } vscode.env.clipboard.writeText(secureUrl).then(() => { + analytics?.track("copy_asset_url", { copy_type: "secure_url" }); vscode.window.showInformationMessage(`Copied URL: ${secureUrl}`); }); } @@ -31,6 +33,7 @@ function registerClipboard(context: vscode.ExtensionContext) { return; } vscode.env.clipboard.writeText(publicId).then(() => { + analytics?.track("copy_asset_public_id", { copy_type: "public_id" }); vscode.window.showInformationMessage(`Copied Public ID: ${publicId}`); }); } @@ -47,6 +50,7 @@ function registerClipboard(context: vscode.ExtensionContext) { return; } vscode.env.clipboard.writeText(optimizedUrl).then(() => { + analytics?.track("copy_asset_url", { copy_type: "optimized_url" }); vscode.window.showInformationMessage(`Copied Optimized URL: ${optimizedUrl}`); }); } diff --git a/src/commands/previewAsset.ts b/src/commands/previewAsset.ts index 29d208d..2462898 100644 --- a/src/commands/previewAsset.ts +++ b/src/commands/previewAsset.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { AnalyticsService } from "../analytics/analyticsService"; import { createWebviewDocument, getScriptUri } from "../webview/webviewUtils"; import { escapeHtml, formatFileSize } from "../webview/utils/helpers"; import { assetIcons, actionIcons } from "../webview/icons"; @@ -14,6 +15,7 @@ type AssetData = { filename: string; format?: string; resource_type?: string; + type?: string; tags?: string[]; context?: Record; metadata?: Record; @@ -38,12 +40,17 @@ function getAssetIcon(type: string): string { } } -function registerPreview(context: vscode.ExtensionContext) { +function registerPreview(context: vscode.ExtensionContext, analytics?: AnalyticsService) { context.subscriptions.push( vscode.commands.registerCommand( "cloudinary.openAsset", (asset: AssetData) => { const publicId = asset.public_id; + analytics?.track("asset_opened", { + resource_type: asset.resource_type || asset.displayType, + asset_type: asset.type, + entry_point: "preview_command", + }); // Check if panel for this asset already exists const existingPanel = openPanels.get(publicId); @@ -112,6 +119,7 @@ function registerPreview(context: vscode.ExtensionContext) { panel.webview.onDidReceiveMessage(async (message) => { if (message.command === "copyToClipboard" && message.text) { await vscode.env.clipboard.writeText(message.text); + analytics?.track("copy_asset_url", { copy_type: "preview" }); } }); } @@ -135,6 +143,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 +238,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 +293,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 +306,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 +347,10 @@ function getPreviewContent(asset: AssetData): string { Type ${escapeHtml(asset.displayType || "unknown")}
+
+ Delivery + ${escapeHtml(asset.type || "upload")}${authenticatedBadgeHtml} +
@@ -319,20 +369,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..009da2e 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -8,54 +8,102 @@ 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"; +import { DocsAiViewProvider } from "../webview/docsAiView"; +import { AnalyticsService } from "../analytics/analyticsService"; /** * 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, + docsAiProvider: DocsAiViewProvider, + analytics?: AnalyticsService ) { context.subscriptions.push( vscode.commands.registerCommand("cloudinary.showHomescreen", () => { + analytics?.track("home_opened", { entry_point: "command" }); + docsAiProvider.requestRecentConversations(); vscode.commands.executeCommand("setContext", "cloudinary.activeView", "homescreen"); + vscode.commands.executeCommand("workbench.view.extension.cloudinary"); + setTimeout(() => { + void homescreenProvider.refresh(); + }, 150); }) ); context.subscriptions.push( - vscode.commands.registerCommand("cloudinary.showLibrary", () => { + vscode.commands.registerCommand("cloudinary.showLibrary", (initialSearchQuery?: string) => { + const query = typeof initialSearchQuery === "string" && initialSearchQuery.trim() + ? initialSearchQuery.trim() + : null; + analytics?.track("library_opened", { entry_point: query ? "search" : "command" }); + if (query) { + void libraryWebview.setSearch(query); + } vscode.commands.executeCommand("setContext", "cloudinary.activeView", "library"); vscode.commands.executeCommand("workbench.view.extension.cloudinary"); }) ); context.subscriptions.push( - vscode.commands.registerCommand("cloudinary.refresh", () => - provider.refresh({ - folderPath: '', - nextCursor: null, - searchQuery: null, - resourceTypeFilter: 'all' - }) - ) + vscode.commands.registerCommand("cloudinary.showDocsAI", (initialPrompt?: string) => { + analytics?.track("docs_ai_opened", { + entry_point: "command", + has_initial_prompt: !!initialPrompt, + }); + docsAiProvider.queuePrompt(initialPrompt); + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "docsAi"); + vscode.commands.executeCommand("workbench.view.extension.cloudinary"); + setTimeout(() => { + vscode.commands.executeCommand("cloudinaryDocsAI.focus"); + docsAiProvider.flushPendingPrompt(250); + }, 150); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.showDocsAIConversation", (conversationId?: string) => { + analytics?.track("docs_ai_opened", { + entry_point: "command", + has_initial_conversation: !!conversationId, + }); + docsAiProvider.queueConversation(conversationId); + vscode.commands.executeCommand("setContext", "cloudinary.activeView", "docsAi"); + vscode.commands.executeCommand("workbench.view.extension.cloudinary"); + setTimeout(() => { + vscode.commands.executeCommand("cloudinaryDocsAI.focus"); + docsAiProvider.flushPendingConversation(250); + }, 150); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("cloudinary.refresh", async () => { + analytics?.track("library_refreshed", { entry_point: "command" }); + await libraryWebview.refresh(); + }) ); - registerSearch(context, provider, homescreenProvider); - registerClearSearch(context, provider); - registerViewOptions(context, provider); - registerPreview(context); - registerUpload(context, provider); - registerClipboard(context); - registerSwitchEnv(context, provider, statusBar); - registerWelcomeScreen(context, provider); + registerSearch(context, homescreenProvider); + registerClearSearch(context, libraryWebview); + registerViewOptions(context, libraryWebview); + registerPreview(context, analytics); + registerUpload(context, cloudinaryService, analytics); + registerClipboard(context, analytics); + registerSwitchEnv(context, environmentTarget, statusBar, analytics); + 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..1f1d1b3 100644 --- a/src/commands/switchEnvironment.ts +++ b/src/commands/switchEnvironment.ts @@ -1,9 +1,12 @@ 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 { detectFolderModeResult, resolveFolderModeState } from "../config/detectFolderMode"; +import { readFolderModeCache, writeFolderModeCache } from "../config/folderModeCache"; import { generateUserAgent } from "../utils/userAgent"; +import { AnalyticsService } from "../analytics/analyticsService"; +import { trackConfigValidation } from "../analytics/trackConfigValidation"; interface CloudinaryEnvironment { apiKey: string; @@ -11,16 +14,70 @@ interface CloudinaryEnvironment { uploadPreset?: string; // Optional: Default upload preset } +type EnvironmentTarget = Pick< + CloudinaryService, + "cloudName" | "apiKey" | "apiSecret" | "uploadPreset" | "dynamicFolders" | "credentialsValid" +> & { + setCredentials?: (creds: Credentials) => void; +}; + +function updateEnvironmentTarget( + target: EnvironmentTarget, + credentials: Credentials, + credentialsValid: boolean | undefined +) { + target.credentialsValid = credentialsValid; + + 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, + credentialsValid?: boolean +): string { + if (credentialsValid === false) { + return `$(warning) ${cloudName}: credentials invalid`; + } + if (credentialsValid === undefined) { + return `$(cloud) ${cloudName}`; + } + const folderMode = dynamicFolders ? "Dynamic" : "Fixed"; + return `$(cloud) ${cloudName} $(folder) ${folderMode}`; +} + +function getStatusBarTooltip(dynamicFolders: boolean, credentialsValid?: boolean): string { + if (credentialsValid === false) { + return "Cloudinary credentials are invalid or unauthorized.\n\nClick to switch environment, or update your config."; + } + if (credentialsValid === undefined) { + return "Click to switch Cloudinary environment"; + } + 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, - statusBar: vscode.StatusBarItem + target: EnvironmentTarget, + statusBar: vscode.StatusBarItem, + analytics?: AnalyticsService ) { context.subscriptions.push( vscode.commands.registerCommand( @@ -30,7 +87,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,25 +97,30 @@ function registerSwitchEnv( if (selected) { const env = environments[selected]; + const cached = readFolderModeCache(context.globalState, selected); + + // Always validate the current credentials. The folder-mode cache is + // keyed only by cloud name, so it can provide a fallback mode but + // cannot prove that the current API key/secret are valid. + const result = await detectFolderModeResult( + selected, + env.apiKey, + env.apiSecret + ); + trackConfigValidation(analytics, result, "switch"); - 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; - - if (typeof cachedFolderMode === "boolean") { - provider.dynamicFolders = cachedFolderMode; - } else { - provider.dynamicFolders = await detectFolderMode( - selected, - env.apiKey, - env.apiSecret - ); - context.globalState.update(cacheKey, provider.dynamicFolders); + if (result.outcome === "success") { + await writeFolderModeCache(context.globalState, selected, result.dynamicFolders); } + const { dynamicFolders, credentialsValid } = resolveFolderModeState(result, cached); + + updateEnvironmentTarget(target, { + cloudName: selected, + apiKey: env.apiKey, + apiSecret: env.apiSecret, + uploadPreset: env.uploadPreset || null, + dynamicFolders, + }, credentialsValid); (cloudinary.utils as any).userPlatform = generateUserAgent(); @@ -68,25 +130,18 @@ 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, credentialsValid); + statusBar.tooltip = getStatusBarTooltip(dynamicFolders, credentialsValid); - vscode.window.showInformationMessage( - `🔄 Switched to ${selected} environment.` - ); + if (credentialsValid === false) { + vscode.window.showWarningMessage( + `Switched to ${selected}, but its Cloudinary credentials are invalid or unauthorized.` + ); + } else { + vscode.window.showInformationMessage( + `Switched to ${selected} environment.` + ); + } } } ) diff --git a/src/commands/uploadWidget.ts b/src/commands/uploadWidget.ts index f510c9e..4c3828a 100644 --- a/src/commands/uploadWidget.ts +++ b/src/commands/uploadWidget.ts @@ -1,5 +1,7 @@ import * as vscode from "vscode"; -import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { AnalyticsService } from "../analytics/analyticsService"; +import { CloudinaryService } from "../cloudinary/cloudinaryService"; +import { UploadPreset } from "../cloudinary/types"; import { v2 as cloudinary } from "cloudinary"; import { Readable } from "stream"; import { @@ -16,6 +18,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. */ @@ -35,19 +61,103 @@ const CHUNKED_UPLOAD_THRESHOLD = 20 * 1024 * 1024; * Chunk size for chunked uploads (6 MB). */ const UPLOAD_CHUNK_SIZE = 6 * 1024 * 1024; +const FOLDER_LOAD_FALLBACK_MS = 4000; +// Safety cap on waiting for the client's "ready" handshake. The handshake +// normally resolves in tens of ms (as soon as the webview script loads); this +// only prevents an indefinite hang if the message is somehow never received. +const READY_HANDSHAKE_TIMEOUT_MS = 5000; +const ROOT_FOLDER_OPTIONS: FolderOption[] = [{ path: "", label: "/ (root)" }]; +const folderOptionsCache = new Map(); + +function cloneFolderOptions(folders: FolderOption[]): FolderOption[] { + return folders.map((folder) => ({ ...folder })); +} + +function getCachedFolderOptions(cloudName: string | null | undefined): FolderOption[] | undefined { + if (!cloudName) { + return undefined; + } + + const cached = folderOptionsCache.get(cloudName); + return cached ? cloneFolderOptions(cached) : undefined; +} + +function setCachedFolderOptions(cloudName: string | null | undefined, folders: FolderOption[]): void { + if (!cloudName) { + return; + } + + folderOptionsCache.set(cloudName, cloneFolderOptions(folders)); +} + +function getInitialFolderOptions(cloudName: string | null | undefined): FolderOption[] { + return getCachedFolderOptions(cloudName) ?? cloneFolderOptions(ROOT_FOLDER_OPTIONS); +} + +function clearFolderOptionsCache(): void { + folderOptionsCache.clear(); +} + +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, + analytics?: AnalyticsService ) { + const cloudinaryState = getUploadCloudinaryState(cloudinaryStateTarget); + context.subscriptions.push( vscode.commands.registerCommand("cloudinary.openUploadWidget", async () => { try { - await provider.fetchUploadPresets(); - openOrRevealUploadPanel("", provider, context); + analytics?.track("upload_widget_opened", { entry_point: "command" }); + await openOrRevealUploadPanel("", cloudinaryState, context, analytics); } catch (err: any) { vscode.window.showErrorMessage( `Failed to open upload widget: ${err.message}` @@ -61,9 +171,12 @@ function registerUpload( "cloudinary.uploadToFolder", async (folderItem: { label: string; data: { path?: string } }) => { try { - await provider.fetchUploadPresets(); const folderPath = folderItem.data.path || ""; - openOrRevealUploadPanel(folderPath, provider, context); + analytics?.track("upload_widget_opened", { + entry_point: "folder_context", + folder_selected: !!folderPath, + }); + await openOrRevealUploadPanel(folderPath, cloudinaryState, context, analytics); } catch (err: any) { vscode.window.showErrorMessage( `Failed to open upload widget: ${err.message}` @@ -87,32 +200,63 @@ 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, - context: vscode.ExtensionContext -) { + cloudinaryState: UploadWidgetCloudinaryState, + context: vscode.ExtensionContext, + analytics?: AnalyticsService +): 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, analytics); 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 +273,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 +290,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,21 +307,46 @@ async function uploadWithProgress( } /** - * Collects folder options from the provider's cache. + * Collects folder options from the Cloudinary service. */ -function collectFolderOptions(provider: CloudinaryTreeDataProvider): FolderOption[] { - const folders: FolderOption[] = [{ path: "", label: "/ (root)" }]; - for (const folder of provider.getAvailableFolders()) { - folders.push({ path: folder.path, label: folder.path }); +async function collectFolderOptions( + cloudinaryState: UploadWidgetCloudinaryState +): Promise { + const folders: FolderOption[] = [...ROOT_FOLDER_OPTIONS]; + const seenPaths = new Set(); + const cachedFolders = getCachedFolderOptions(cloudinaryState.cloudName); + + 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 cachedFolders ?? folders; } + + setCachedFolderOptions(cloudinaryState.cloudName, folders); return folders; } +// Exported for the extension test harness; not part of the command surface. +export const uploadWidgetTestApi = { + clearFolderOptionsCache, + collectFolderOptions, + getUploadOptions, + getInitialFolderOptions, + getUploadContent, +}; + /** * Creates upload options for the Cloudinary API. */ function getUploadOptions( - provider: CloudinaryTreeDataProvider, + cloudinaryState: UploadWidgetCloudinaryState, presetName: string | null | undefined, folder: string, publicId?: string, @@ -191,7 +360,7 @@ function getUploadOptions( } if (folder) { - if (provider.dynamicFolders) { + if (cloudinaryState.dynamicFolders) { options.asset_folder = folder; } else { options.folder = folder; @@ -200,7 +369,7 @@ function getUploadOptions( if (fileName) { options.filename_override = fileName; - if (provider.dynamicFolders) { + if (cloudinaryState.dynamicFolders) { options.display_name = fileName.replace(/\.[^/.]+$/, ""); } } @@ -222,11 +391,12 @@ function getUploadOptions( /** * Creates the webview panel with custom upload UI. */ -function createUploadPanel( - provider: CloudinaryTreeDataProvider, - context: vscode.ExtensionContext -): vscode.WebviewPanel { - const cloudName = provider.cloudName!; +async function createUploadPanel( + cloudinaryState: UploadWidgetCloudinaryState, + context: vscode.ExtensionContext, + analytics?: AnalyticsService +): Promise { + const cloudName = cloudinaryState.cloudName!; const panel = vscode.window.createWebviewPanel( "cloudinaryUploadWidget", `Upload — ${cloudName}`, @@ -246,8 +416,17 @@ function createUploadPanel( "cloudinary_icon_blue.png" ); - const currentPreset = provider.getCurrentUploadPreset() || ""; - const folders = collectFolderOptions(provider); + const currentPreset = cloudinaryState.getCurrentUploadPreset() || ""; + const initialFolders = getInitialFolderOptions(cloudName); + + // The client posts "ready" once its message listener is attached. The deferred + // load below waits on this before posting folders/presets, so updates can't be + // dropped by landing before the listener exists (a fixed delay races the + // webview's load time and was the cause of the empty-dropdown bug). + let resolveClientReady: (() => void) | undefined; + const clientReady = new Promise((resolve) => { + resolveClientReady = resolve; + }); const uploadScriptUri = getScriptUri( panel.webview, @@ -255,8 +434,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)}, @@ -264,18 +444,14 @@ function createUploadPanel( }); `; - panel.webview.html = createWebviewDocument({ - title: "Upload to Cloudinary", - webview: panel.webview, - extensionUri: context.extensionUri, - bodyContent: getUploadContent(currentPreset, provider, currentFolderPath, folders), - bodyClass: "layout-centered", - additionalScripts: [uploadScriptUri], - inlineScript: initScript, - }); - // Handle messages from the webview panel.webview.onDidReceiveMessage(async (message: any) => { + if (message.command === "ready") { + resolveClientReady?.(); + resolveClientReady = undefined; + return; + } + if (message.command === "folderChanged" && message.folderPath !== undefined) { currentFolderPath = message.folderPath; return; @@ -285,7 +461,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,21 +470,40 @@ function createUploadPanel( ); try { - panel.webview.postMessage({ command: "uploadStarted", fileId: message.fileId }); + analytics?.track("upload_started", { + upload_source: "file", + folder_selected: !!folder, + preset_selected: !!presetName, + }); + 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, }); + analytics?.track("upload_succeeded", { + upload_source: "file", + resource_type: result.resource_type, + asset_type: result.type, + folder_selected: !!folder, + preset_selected: !!presetName, + }); vscode.commands.executeCommand("cloudinary.refresh"); } catch (err: any) { - panel.webview.postMessage({ + analytics?.track("upload_failed", { + upload_source: "file", + folder_selected: !!folder, + preset_selected: !!presetName, + }); + safePostToPanel(panel, { command: "uploadError", fileId: message.fileId, error: err.message || "Upload failed", @@ -333,7 +528,7 @@ function createUploadPanel( } const options = getUploadOptions( - provider, + cloudinaryState, presetName, folder, message.publicId, @@ -343,17 +538,36 @@ 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 }); + analytics?.track("upload_started", { + upload_source: "url", + folder_selected: !!folder, + preset_selected: !!presetName, + }); + 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 }); + analytics?.track("upload_succeeded", { + upload_source: "url", + resource_type: result.resource_type, + asset_type: result.type, + folder_selected: !!folder, + preset_selected: !!presetName, + }); vscode.commands.executeCommand("cloudinary.refresh"); } catch (err: any) { - panel.webview.postMessage({ + analytics?.track("upload_failed", { + upload_source: "url", + folder_selected: !!folder, + preset_selected: !!presetName, + }); + safePostToPanel(panel, { command: "uploadError", fileId, error: err.message || "Upload failed", @@ -364,28 +578,23 @@ function createUploadPanel( if (message.command === "copyToClipboard" && message.text) { await vscode.env.clipboard.writeText(message.text); + analytics?.track("copy_asset_url", { copy_type: "upload_result" }); } if (message.command === "refreshFolders") { - const updatedFolders = collectFolderOptions(provider); - panel.webview.postMessage({ command: "updateFolders", folders: updatedFolders }); + const updatedFolders = await collectFolderOptions(cloudinaryState); + safePostToPanel(panel, { + command: "updateFolders", + folderPath: currentFolderPath, + 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 || @@ -393,15 +602,95 @@ function createUploadPanel( asset.public_id.split("/").pop() || asset.public_id; + analytics?.track("asset_opened", { + entry_point: "upload_result", + resource_type: assetType, + asset_type: asset.type, + }); vscode.commands.executeCommand("cloudinary.openAsset", { ...asset, displayType: assetType, + secure_url: signedOriginalUrl || asset.secure_url, optimized_url: optimizedUrl, filename, }); } }); + panel.webview.html = createWebviewDocument({ + title: "Upload to Cloudinary", + webview: panel.webview, + extensionUri: context.extensionUri, + bodyContent: getUploadContent( + currentPreset, + cloudinaryState.uploadPresets, + currentFolderPath, + initialFolders + ), + bodyClass: "layout-centered", + additionalScripts: [uploadScriptUri], + inlineScript: initScript, + }); + + // Deferred load: fetch presets and folders in parallel without blocking the + // initial render. Posts updates to the client when each completes. + void (async () => { + // Wait for the client's "ready" handshake before posting, falling back to a + // short delay so a missed handshake can't hang the load indefinitely. + await Promise.race([ + clientReady, + new Promise((resolve) => setTimeout(resolve, READY_HANDSHAKE_TIMEOUT_MS)), + ]); + + const presetsTask = cloudinaryState + .fetchUploadPresets() + .then((presets) => { + safePostToPanel(panel, { + command: "updatePresets", + preset: cloudinaryState.getCurrentUploadPreset() || "", + presets, + }); + }) + .catch((err) => { + console.error("Failed to fetch upload presets:", err); + }); + + const postFolderOptions = (folders: FolderOption[]) => { + safePostToPanel(panel, { + command: "updateFolders", + folderPath: currentFolderPath, + folders, + }); + }; + + let folderLoadSettled = false; + const folderLoadFallback = setTimeout(() => { + if (!folderLoadSettled) { + postFolderOptions(getInitialFolderOptions(cloudName)); + } + }, FOLDER_LOAD_FALLBACK_MS); + + const foldersTask = collectFolderOptions(cloudinaryState) + .then((folders) => { + folderLoadSettled = true; + clearTimeout(folderLoadFallback); + safePostToPanel(panel, { + command: "updateFolders", + folderPath: currentFolderPath, + folders, + }); + }) + .catch((err) => { + folderLoadSettled = true; + clearTimeout(folderLoadFallback); + console.error("Failed to load folders:", err); + // Clear the loading state and keep root usable even if the fetch failed. + postFolderOptions(ROOT_FOLDER_OPTIONS); + }); + + await Promise.all([presetsTask, foldersTask]); + })(); + return panel; } @@ -410,7 +699,7 @@ function createUploadPanel( */ function getUploadContent( currentPreset: string, - provider: CloudinaryTreeDataProvider, + uploadPresets: UploadPreset[], initialFolderPath: string, folders: FolderOption[] ): string { @@ -420,7 +709,7 @@ function getUploadContent( ) .join(""); - const presetOptionsHtml = provider.uploadPresets + const presetOptionsHtml = uploadPresets .map( (p) => `` ) @@ -435,6 +724,7 @@ function getUploadContent(
+
Loading folders…
@@ -442,7 +732,7 @@ function getUploadContent(
- ${provider.uploadPresets.length > 0 ? '' : ""} + ${uploadPresets.length > 0 ? '' : ""}
diff --git a/src/commands/viewOptions.ts b/src/commands/viewOptions.ts index 9e82f08..6a80dbf 100644 --- a/src/commands/viewOptions.ts +++ b/src/commands/viewOptions.ts @@ -1,24 +1,31 @@ import * as vscode from "vscode"; -import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { LibraryWebviewViewProvider } from "../webview/libraryView"; type ResourceType = "all" | "image" | "video" | "raw"; type SortDirection = "asc" | "desc"; +type ViewStateUpdate = { + resourceTypeFilter?: ResourceType; + sortDirection?: SortDirection; +}; +type ViewAwareLibraryWebview = LibraryWebviewViewProvider & { + applyView?: (opts: ViewStateUpdate) => Thenable | Promise; +}; interface ViewOption { label: string; description?: string; kind?: vscode.QuickPickItemKind; - action?: () => void; + viewState?: ViewStateUpdate; } /** * Registers a command that opens a Quick Pick with view options (filter and sort). * @param context - VS Code extension context. - * @param provider - Cloudinary tree data provider instance. + * @param libraryWebview - Cloudinary library webview provider. */ function registerViewOptions( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider + libraryWebview?: LibraryWebviewViewProvider ) { context.subscriptions.push( vscode.commands.registerCommand( @@ -30,34 +37,34 @@ function registerViewOptions( { label: "$(file) All Types", description: "Show all asset types", - action: () => provider.refresh({ resourceTypeFilter: "all" as ResourceType }), + viewState: { resourceTypeFilter: "all" as ResourceType }, }, { label: "$(file-media) Images Only", description: "Show only images", - action: () => provider.refresh({ resourceTypeFilter: "image" as ResourceType }), + viewState: { resourceTypeFilter: "image" as ResourceType }, }, { label: "$(device-camera-video) Videos Only", description: "Show only videos", - action: () => provider.refresh({ resourceTypeFilter: "video" as ResourceType }), + viewState: { resourceTypeFilter: "video" as ResourceType }, }, { label: "$(file-binary) Raw Files Only", description: "Show only raw files", - action: () => provider.refresh({ resourceTypeFilter: "raw" as ResourceType }), + viewState: { resourceTypeFilter: "raw" as ResourceType }, }, // Sort section { label: "Sort Order", kind: vscode.QuickPickItemKind.Separator }, { label: "$(arrow-down) Newest First", description: "Most recently uploaded assets first", - action: () => provider.refresh({ sortDirection: "desc" as SortDirection }), + viewState: { sortDirection: "desc" as SortDirection }, }, { label: "$(arrow-up) Oldest First", description: "Oldest uploaded assets first", - action: () => provider.refresh({ sortDirection: "asc" as SortDirection }), + viewState: { sortDirection: "asc" as SortDirection }, }, ]; @@ -66,8 +73,8 @@ function registerViewOptions( matchOnDescription: true, }); - if (selected && selected.action) { - selected.action(); + if (selected?.viewState) { + await (libraryWebview as ViewAwareLibraryWebview | undefined)?.applyView?.(selected.viewState); } } ) diff --git a/src/commands/welcomeScreen.ts b/src/commands/welcomeScreen.ts index c32a18f..3b02bbf 100644 --- a/src/commands/welcomeScreen.ts +++ b/src/commands/welcomeScreen.ts @@ -1,32 +1,59 @@ import * as vscode from "vscode"; -import { CloudinaryTreeDataProvider } from "../tree/treeDataProvider"; +import { CloudinaryService } from "../cloudinary/cloudinaryService"; import { createWebviewDocument, getScriptUri, } from "../webview/webviewUtils"; import { escapeHtml } from "../webview/utils/helpers"; +import { getConnectionStatus } from "../config/connectionStatus"; + +type WelcomeScreenCloudinaryState = Pick< + CloudinaryService, + "cloudName" | "apiKey" | "apiSecret" | "credentialsValid" +>; + +let welcomePanel: vscode.WebviewPanel | undefined; +let welcomeContext: vscode.ExtensionContext | undefined; +let welcomeCloudinaryState: WelcomeScreenCloudinaryState | undefined; /** * Registers the welcome screen command. */ function registerWelcomeScreen( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider + cloudinaryState: WelcomeScreenCloudinaryState ) { + welcomeContext = context; + welcomeCloudinaryState = cloudinaryState; + context.subscriptions.push( vscode.commands.registerCommand("cloudinary.openWelcomeScreen", () => { - createWelcomePanel(context, provider); + createWelcomePanel(context, cloudinaryState); }) ); } +export function refreshWelcomePanel(): void { + if (!welcomePanel || !welcomeContext || !welcomeCloudinaryState) { + return; + } + + renderWelcomePanel(welcomePanel, welcomeContext, welcomeCloudinaryState); +} + /** * Creates the welcome screen webview panel. */ function createWelcomePanel( context: vscode.ExtensionContext, - provider: CloudinaryTreeDataProvider + cloudinaryState: WelcomeScreenCloudinaryState ): vscode.WebviewPanel { + if (welcomePanel) { + welcomePanel.reveal(vscode.ViewColumn.One); + renderWelcomePanel(welcomePanel, context, cloudinaryState); + return welcomePanel; + } + const panel = vscode.window.createWebviewPanel( "cloudinaryWelcome", "Welcome to Cloudinary", @@ -39,6 +66,7 @@ function createWelcomePanel( ], } ); + welcomePanel = panel; panel.iconPath = vscode.Uri.joinPath( context.extensionUri, @@ -46,18 +74,10 @@ function createWelcomePanel( "cloudinary_icon_blue.png" ); - const welcomeScriptUri = getScriptUri( - panel.webview, - context.extensionUri, - "welcome.js" - ); + renderWelcomePanel(panel, context, cloudinaryState); - panel.webview.html = createWebviewDocument({ - title: "Welcome to Cloudinary", - webview: panel.webview, - extensionUri: context.extensionUri, - bodyContent: getWelcomeContent(provider), - additionalScripts: [welcomeScriptUri], + panel.onDidDispose(() => { + welcomePanel = undefined; }); panel.webview.onDidReceiveMessage((message: { command: string; data?: string; text?: string }) => { @@ -77,8 +97,8 @@ function createWelcomePanel( vscode.env.openExternal(vscode.Uri.parse(message.data)); } break; - case "focusTreeView": - vscode.commands.executeCommand("workbench.view.extension.cloudinary"); + case "focusDashboard": + vscode.commands.executeCommand("cloudinary.showHomescreen"); break; } }); @@ -86,12 +106,60 @@ function createWelcomePanel( return panel; } +function renderWelcomePanel( + panel: vscode.WebviewPanel, + context: vscode.ExtensionContext, + cloudinaryState: WelcomeScreenCloudinaryState +): void { + const welcomeScriptUri = getScriptUri( + panel.webview, + context.extensionUri, + "welcome.js" + ); + + panel.webview.html = createWebviewDocument({ + title: "Welcome to Cloudinary", + webview: panel.webview, + extensionUri: context.extensionUri, + bodyContent: getWelcomeContent(cloudinaryState), + additionalScripts: [welcomeScriptUri], + }); +} + /** * Generates the welcome screen body content. */ -function getWelcomeContent(provider: CloudinaryTreeDataProvider): string { - const hasConfig = !!(provider.cloudName && provider.apiKey); - const cloudName = escapeHtml(provider.cloudName || ""); +function getWelcomeContent(cloudinaryState: WelcomeScreenCloudinaryState): string { + const status = getConnectionStatus({ + cloudName: cloudinaryState.cloudName, + apiKey: cloudinaryState.apiKey, + apiSecret: cloudinaryState.apiSecret, + credentialsValid: cloudinaryState.credentialsValid, + }); + const isConnected = status === "connected"; + const isChecking = status === "checking"; + const isInvalidCredentials = status === "invalidCredentials"; + const hasCredentials = !!(cloudinaryState.cloudName && cloudinaryState.apiKey && cloudinaryState.apiSecret); + const cloudName = escapeHtml(cloudinaryState.cloudName || ""); + const statusClass = isConnected ? "ok" : isChecking ? "neutral" : "warn"; + const statusLabel = isConnected + ? `Connected to ${cloudName}` + : isChecking + ? `Checking ${cloudName || "credentials"}` + : isInvalidCredentials + ? "Cloudinary credentials are invalid" + : hasCredentials + ? "Credentials need attention" + : "No credentials configured"; + const statusDetail = isConnected + ? "Your environment is ready. Open the dashboard to explore your media." + : isChecking + ? "Validating your Cloudinary credentials before opening the dashboard." + : isInvalidCredentials + ? "Check that the cloud name, API key, and API secret belong to the same product environment." + : hasCredentials + ? "Update your Cloudinary API credentials to connect." + : "Add your Cloudinary API credentials to get started."; return `