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/TESTING_GUIDE_v1.0.0.md b/TESTING_GUIDE_v1.0.0.md new file mode 100644 index 0000000..722cac7 --- /dev/null +++ b/TESTING_GUIDE_v1.0.0.md @@ -0,0 +1,183 @@ +# Testing guide: Cloudinary VS Code extension v1.0.0 + +Hey team, this build is for the v1.0.0 GA release of the VS Code extension. Below is what's new and what to poke at. + +> 💬 **Feedback:** Please share your feedback about the extension in the shared Google Doc. Bugs welcome, copy nits welcome, "this UX is confusing" welcome. +> For comments about the AI responses, please use the thumbs up/down feature on each message to leave feedback tied to conversations. + +--- + +## 1. Install + +1. Save `cloudinary-1.0.0.vsix` somewhere on your Mac. +2. In VS Code: + - `Cmd+Shift+P` → **Extensions: Install from VSIX…** → pick the file + - **or** open your extensions pane, then drag and drop the VSIX file into it +3. Reload: `Cmd+Shift+P` → **Developer: Reload Window**. +4. Open the **Cloudinary icon** in the Activity Bar + +### If you've never set up Cloudinary credentials before + +You should see a "Add your API credentials to connect" banner on the homescreen. Click **Configure** to open the global config file. Paste your cloud name + API key + API secret. Save the file. The banner should disappear and your cloud name should appear at the top. + +If you already have credentials configured, skip ahead. + +--- + +## 2. Smoke test: does anything obviously break? + +- [ ] The Cloudinary icon shows in the Activity Bar. +- [ ] Clicking it opens the homescreen with no errors. +- [ ] Your cloud name appears at the top of the homescreen. +- [ ] The "Browse Library", "Upload", and "Configure AI Tools" tiles all render. +- [ ] The "Ask Cloudinary AI" section (heading + textarea + suggested-question chips) renders below the tiles. +- [ ] No red error notifications pop up. + +If any of these fail, stop and report. The rest of the guide assumes the basics work. + +--- + +## 3. Homescreen + +The homescreen is the new landing view. Test: + +- [ ] **Search box**: type a query (e.g. `transformation`). Hitting Enter should open the library view with that search applied. +- [ ] **Status pill (top right)**: the text reads "Connected" when your credentials are valid, "Setup needed" otherwise. There's a small dot beside the text that changes appearance when not configured. +- [ ] **Folder mode chip**: shows "Dynamic folders" or "Fixed folders" beside your cloud name (only when credentials are configured). +- [ ] **Browse Library tile**: clicking opens the library view. +- [ ] **Upload tile**: clicking opens the upload widget panel. +- [ ] **Switch Environment tile**: only appears if you have 2+ environments configured. Clicking it should let you swap. +- [ ] **Configure AI Tools tile**: clicking expands an inline panel below the tile (see Section 6). Clicking again collapses it. +- [ ] **Ask Cloudinary AI section**: heading "Ask Cloudinary AI", subtitle, a "Send a message…" textarea, a send button, and 4 suggested-question chips ("What's new in Cloudinary?", "How do I upload images?", "Explain image transformations", "What SDKs does Cloudinary support?"). Pressing Enter or clicking send takes you into Docs AI with that prompt pre-filled. +- [ ] **View recent conversations**: a clock-icon toggle appears below the Ask Cloudinary AI section once you have one or more past chats. Clicking it expands a list of recent conversations. +- [ ] **Welcome Guide** link in the footer. + +**Things to look for:** +- Does the homescreen render quickly? It's supposed to paint a static shell first and load data after. +- Any visual jank when you resize the sidebar narrow vs. wide? +- Anything that feels off in the copy? + +--- + +## 4. Library view + +Open the library by clicking the "Browse Library" tile on the homescreen. This swaps the Cloudinary sidebar from the homescreen over to the library view. To get back, click the Home button in the toolbar at the top of the library view. + +- [ ] **Folder expand**: clicking a folder reveals its contents. Big folders should stream in (a loading row appears at the bottom). +- [ ] **Search**: the search input at the top filters across the library. Pressing Escape clears it. +- [ ] **Type filter**: dropdown with All / Images / Videos / Raw. +- [ ] **Sort**: dropdown with Newest / Oldest. +- [ ] **Click an asset**: opens a preview panel with optimized image/video, public ID, copy buttons, and metadata. +- [ ] **Right-click an asset**: context menu with options to copy the public ID, the URL, and the optimized URL. +- [ ] **Switch environment while a preview is open**: the preview panel closes cleanly (rather than trying to update in place with stale data). + +**Things to look for:** +- Does scrolling stay smooth with hundreds of items? +- Does the loading indicator behave correctly at the bottom? +- Are folder icons and asset thumbnails rendering properly? + +--- + +## 5. Docs AI chat: biggest new feature + +This is the in-editor doc chatbot. The Cloudinary sidebar shows one view at a time (Homescreen, Library, or Docs AI), so opening Docs AI swaps the sidebar over to that view. Ways to get there: + +- Type in the "Ask Cloudinary AI" textarea on the homescreen and submit (sends your prompt and opens Docs AI). +- Click any of the suggested-question chips on the homescreen. +- Click a recent conversation entry on the homescreen (after you have any). +- Run "Open Docs AI" from the Command Palette. + +To return to the homescreen, click the Home button in the toolbar at the top of the Docs AI view. + +The Docs AI view contains: a tab strip at the top (one tab per conversation), a "+" button to start a new chat, a clock-icon button for chat history, a conversation area, and a composer at the bottom with a textarea (placeholder "Send a message…"), a send button, and a stop button. + +- [ ] **Ask a basic question** (e.g. "How do I upload an image with Node.js?"). The response should stream in word by word. +- [ ] **Inline citations**: numbered citation markers appear in the answer text and link to the corresponding sources. +- [ ] **Sources section**: a collapsible "Sources" block sits below the answer with a chevron toggle. Clicking a source link opens the docs page in your browser. +- [ ] **Follow-up questions**: 2-3 suggested follow-up prompts appear below an answer. +- [ ] **Disclaimer line** at the bottom: "AI can make mistakes. Verify important info from Cloudinary docs." + +### Chat history + +- [ ] **New chat (+ button)**: starts a fresh conversation in a new tab. +- [ ] **Tab strip**: each conversation gets its own tab at the top. You can switch between them. +- [ ] **History button (clock icon)**: opens a dropdown of past conversations. +- [ ] **Clear all conversations**: from the history dropdown, there should be a way to delete all. Confirm there's a "Delete all conversations?" confirmation before it deletes. + +### Things to test for content quality + +- [ ] **Recent docs are in the index**: try asking about something that landed in the May 2026 MediaFlows release notes (e.g. "What's the Workflow Agent in MediaFlows?"). It should answer correctly. +- [ ] **Stop generation**: start a long answer, hit the stop button mid-stream. The stream should stop cleanly. +- [ ] **Non-English question**: try Hebrew, Spanish, or another language. The bot should answer in that language. +- [ ] **Code in answers**: questions about code should get back fenced code blocks that look right. +- [ ] **Thumbs up/down on each AI message**: confirm both states register (the bot acknowledges the feedback was saved). + +**Known limitation:** docs marked `noindex: true` are intentionally excluded from the chatbot index (e.g. internal-only pages or Beta features still in alpha). So if you ask about a hidden/beta-only feature, it might not have it. + +--- + +## 6. Configure AI Tools + +This installs Cloudinary skills + MCP servers into your AI coding tools (Cursor, Claude Code, VS Code Copilot, Windsurf, and many more). + +- [ ] Click the **Configure AI Tools** tile on the homescreen. An inline panel expands below it. +- [ ] **Platform dropdown**: pick from the list of supported platforms. The dropdown should default to one that matches your current environment when possible (e.g. "VS Code (Copilot)" when running inside VS Code). +- [ ] **Scope toggle**: two buttons, "Project" and "Global". Toggle between them. +- [ ] **Skills + MCP servers list**: shows checkboxes for each available skill and MCP server. Items already installed for the chosen platform + scope are shown as disabled / pre-checked. +- [ ] **Apply button**: enabled only when you have at least one selection to install. Click it. The button label changes to "Applying…", then each item shows a ✓ (success) or ✕ (failure) tick. +- [ ] **Reopen the panel**: skills you installed should now show as already installed (not re-offering them at the same scope). +- [ ] **Switch platform** mid-flow: changing the platform dropdown should reload the list with whatever is correct for that new platform. + +**Things to look for:** +- Does the platform list cover what you actually use? +- Did it auto-pick the right default platform for you? +- Are the install instructions / cover notes clear? +- Anything that errors silently or fails without a clear message? + +--- + +## 7. Upload widget + +- [ ] Click "Upload" on the homescreen. The upload widget panel opens. +- [ ] Drag a file onto it (or pick one via the file picker). Progress should show in real time. +- [ ] Upload completes, the asset appears in the recently-uploaded section. +- [ ] **Chunked upload**: try a file larger than 20 MB (the threshold for chunked uploads). It should complete reliably without timing out. +- [ ] **Switch environment with the widget open**: the widget should close cleanly rather than try to update in place. +- [ ] The panel title should reflect the active cloud name. + +--- + +## 8. Environment switching + +Only relevant if you have multiple Cloudinary environments configured. + +- [ ] The VS Code **status bar at the bottom** shows a cloud icon followed by the active cloud name. +- [ ] Click the status bar item (or use the Switch Environment tile on the homescreen) to swap environments. +- [ ] After switching: + - Library view refreshes with the new environment's assets. + - Upload presets refetch. + - Any open preview/upload panels close cleanly. + - The folder-mode chip updates if the new environment uses a different mode. + +--- + +## Known limitations / things NOT in this build + +- The "**Docs AI agent actions**" feature (the experimental version where the bot can take actions on your media library directly) is **not** in this build. That's still on a feature branch. +- No marketplace publishing yet. This is a side-load VSIX only. +- KB articles flagged `noindex: true` in our docs source are intentionally excluded from the Docs AI index. + +--- + +## What we'd love to know + +1. **First-impression UX**: what feels off when you first open the extension? +2. **Docs AI answer quality**: does it actually help you find what you'd normally search for in our docs? +3. **Performance**: any slow spots? Sidebar lag, library scroll jank, Docs AI streaming delays? +4. **Copy**: anything in button labels, tooltips, or empty states that doesn't quite read right? +5. **Platform detection** in Configure AI Tools: did it correctly identify what you have installed? +6. **Anything that crashed or showed an error**: please screenshot + the steps to reproduce. + +Drop notes in the shared Google Doc, or reply on the email thread. + +Thanks for testing! 🙏 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 `