From a402f675e3a6df31084484bc34940114c8e556e6 Mon Sep 17 00:00:00 2001 From: NamesMT Date: Wed, 21 May 2025 15:57:19 +0000 Subject: [PATCH 01/23] chore: merge jbbrown/marketplace squashed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detailed commits: Author: Dicha Zelianivan Arkana <51877647+elianiva@users.noreply.github.com> Date: Wed May 21 00:36:02 2025 +0700 feat: better UI for marketplace item list (#11) * feat: better UI for marketplace item list * feat: better source config UI * refactor: change how we fetch items * fix: update state more optimistically * fix: incorrect tags filter * fix: better tags filtering * feat: more consistent UI * feat: marketplace animation * fix: remove cache, it's fast enough * refactor: make the UI more consistent across themes * feat: integrate install metadata to UI * feat: add translation files * test: add marketplace UI commit 1a1166567257f00d3b78f166274e732d30d673a6 Author: Trung Dang Date: Mon May 19 23:08:25 2025 +0700 feat: `installedMetadata` MVP (#10) * feat: `installedMetadata` MVP * feat: removal support for installed items (+ general refactors) commit e25b3e77c0d39d52d34e3c1c96f3a647eb5c0f94 Author: NamesMT Date: Tue May 6 08:40:19 2025 +0000 refactor: minor: cleanup marketplaceHandler passing commit 3dcb4534f85fa9b1846c9719d996271e4cffb5a1 Author: NamesMT Date: Tue May 6 07:53:43 2025 +0000 chore: add reusable `globalContext` util commit b7fae4a325d59e620d95482d477b5c1193ef1b68 Author: NamesMT Date: Tue May 6 06:55:33 2025 +0000 chore(cline_docs/marketplace): align with `Roo-Code-Marketplace` commit f89c11ec67436cf0e8a9d8e1c8f9e21b306b82e2 Author: Trung Dang Date: Mon May 5 14:35:19 2025 +0700 feat(marketplace): UI form for configurable install (#9) * feat(wip): installation UI * chore: rebase, refactor and fixes --------- Co-authored-by: elianiva <51877647+elianiva@users.noreply.github.com> commit bbd790f4bd20ea5259d16a99deed3f02905f9c78 Author: Trung Dang Date: Sun May 4 12:36:52 2025 +0700 chore: bump `roo-rocket` and `config-rocket` version (#7) This is a breaking change that reword a lot of thing, revise the config props, and adds some more features. commit 562c34b9b7d9b7df75bf571c4cfc18e65ac75664 Author: NamesMT Date: Sat May 3 14:57:53 2025 +0000 chore(MM/roo-rocket): shorten and pin CLI version commit a25ed006d60cf6336b20babe43117ef742ed6c2c Author: NamesMT Date: Fri May 2 16:48:39 2025 +0000 chore: adjust `registry` dir find & validate logic commit 065c8af320f04c2656a2caa350c22acfb8110249 Author: NamesMT Date: Fri May 2 16:27:47 2025 +0000 chore: `showInstallButton` for `package` item commit 563b327007432f0d2a85f34945ca5dbd82b2d51a Author: Trung Dang Date: Fri May 2 23:16:26 2025 +0700 chore: marketplace wordings, cleanup, and document refresh (#6) * chore: reword all `PackageManager` => `Marketplace` * chore: document refresh, `mcp-server` => `mcp` * chore: correct word * chore: remove unnecessary condition * chore: remove unnecessary bits * chore: add note for failed test commit c0638798d8db56b36bc1d46b0c35b2a6a7bb1a3c Author: NamesMT Date: Fri May 2 05:13:35 2025 +0000 tests: prepare required mocks for `MarketplaceItemActionsMenu` commit 39b2638c6a2c30076a13cd08acf3e910111d0c5d Author: NamesMT Date: Fri May 2 05:12:55 2025 +0000 chore: remove tests specific to `MarketplaceItemActionsMenu` from ItemCard test file commit 03974e82b2b21c40634f0899834821abec689f20 Author: NamesMT Date: Fri May 2 05:10:35 2025 +0000 refactor: move `isValidUrl` to global util, prop/scope cleaning commit 8ea74f77dda12689e2b4df2a95d62d0fcec11e55 Author: NamesMT Date: Fri May 2 04:28:35 2025 +0000 chore: bump `roo-rocket`, `config-rocket` (fix tests) commit bbfbaa1b308d15f91e741e8e0349bb6bd7dba96a Author: NamesMT Date: Wed Apr 30 19:47:08 2025 +0000 fix: use relative path import instead commit 7274092a6b4385e6a51c251609d9016cac19b114 Author: NamesMT Date: Wed Apr 30 19:30:57 2025 +0000 fix: should display N/A instead of `All` for unknown types commit 8fbc2d95a269f2a9afe40e49a86467aa367c8fa3 Author: NamesMT Date: Wed Apr 30 16:58:16 2025 +0000 style: minor indent & import combine commit 765764790a9c055fe2ff262cc56b4289efd9c0c4 Author: NamesMT Date: Wed Apr 30 16:56:48 2025 +0000 perf: verify binary hash right after download commit bc60b426b5ff3135911a13ab8a8f3a6c520134cd Author: NamesMT Date: Wed Apr 30 16:54:27 2025 +0000 fix: resolve lint problems commit 9354a80b1a1006ac15020a03e8b980ee51897db3 Author: NamesMT Date: Wed Apr 30 16:48:39 2025 +0000 fix: bump version and lockfile, adjust import commit 6a4dc46c80bbf413e4ec573384ebe91b266cbcbb Merge: 29d73dce 324be54e Author: JB Brown Date: Tue Apr 29 06:57:25 2025 -0700 Merge pull request #5 from NamesMT/marketplace/roo-rocket feat: marketplace install MVP commit 324be54e4eddc9073a39aa1600628cf2abbeb9fe Author: NamesMT Date: Mon Apr 28 15:02:59 2025 +0000 chore: minor rewording commit 086523655babb910c5ea9cb2acdf3d7786ab9fd2 Author: NamesMT Date: Mon Apr 28 14:48:52 2025 +0000 feat: working CLI interactive install mode commit 3ac4bf61f2dfa492131e87cb9b4842185c925685 Author: NamesMT Date: Mon Apr 28 12:16:35 2025 +0000 feat: binary configurable pack detection, auto-select installation method commit 3539550b0c78e8554e7ec483072912c0eab3d984 Author: NamesMT Date: Mon Apr 28 10:52:04 2025 +0000 feat: delegate the hooks declaration to `roo-rocket`, prepare for CLI feat commit 26be7b3ac79d7d16f8cb8fb4ac05e5469e6f6a0c Author: NamesMT Date: Mon Apr 28 07:54:10 2025 +0000 fix: should not require workspace folder for `global` install (+ formatting) commit 74a0c44d59259cede9a29e85125d514ba0c7e257 Author: NamesMT Date: Mon Apr 28 06:57:39 2025 +0000 feat: marketplace supports global install commit 04d7360da972b6f52744eb8c19064f968c5f9952 Author: NamesMT Date: Sun Apr 27 20:41:07 2025 +0000 fix: correct error message commit 52b653f27164f7bde1d1e7da57fec42b9f088b50 Author: NamesMT Date: Sun Apr 27 20:24:07 2025 +0000 feat: marketplace install MVP commit 29d73dce4cf0ffeb3449085be869c69d5412e164 Author: Matt Rubens Date: Sun Apr 27 00:11:00 2025 -0400 Knip fixes commit 0e7a14e68a60f194d0567f928b8b084000dbea90 Author: Matt Rubens Date: Sun Apr 27 00:02:10 2025 -0400 Shouldn't need to change this commit 1a6a41d8c0dc64bb762565e972e96834ef9d2d00 Author: Matt Rubens Date: Sat Apr 26 23:54:16 2025 -0400 Revert unrelated changes commit c9449ca2fe898867b12de0e7a6b289b95a62af3f Merge: 4f5b04b6 1924e10e Author: Matt Rubens Date: Sat Apr 26 23:51:13 2025 -0400 Merge remote-tracking branch 'origin/main' into jbbrown/marketplace commit 1924e10e72051e81e60e84b1c3f9b012b47357da Author: Chris Estreich Date: Sat Apr 26 09:45:26 2025 -0700 Fix all linter errors (and fix the lint scripts too) (#2958) commit 418791a743f895f6ca2586f25d0f6215370295e3 Author: Julio Navarro Date: Sat Apr 26 08:46:05 2025 -0400 Fix: custom modes export import (#2810) * Enhance export method in ContextProxy to filter out project custom modes, ensuring only global settings are included in the export. * Fix issue of customModes not being imported when importing settings. The problem was that the Custom modes are managed by the CustomModesManager, not by the context proxy. * * Fix tests * Update webviewMessageHandler to provide customModeManager to importSettings commit 42c1f5f88280edb2568775157e1a7196f27c91b1 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Fri Apr 25 21:03:17 2025 -0700 Changeset version bump (#2957) * changeset version bump * Update package.json * Update package-lock.json * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens commit d6860e1114f552d4b5c45c1968458c4bf79768bf Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri Apr 25 23:53:45 2025 -0400 Update contributors list (#2946) docs: update contributors list [skip ci] Co-authored-by: mrubens commit 5b99e4d50dca78085286c29205621df1d9267af7 Author: Matt Rubens Date: Fri Apr 25 23:51:13 2025 -0400 Updated tips (#2961) * Updated tips * Changeset commit 83133c6e6dc54c752f441889cf7de5c85e170406 Author: Sacha Sayan Date: Fri Apr 25 22:39:11 2025 -0400 UI top down homescreen (#2951) * isotrUpdate welcome screen UI components and fix unused variable * Save collapsibility. * Add persistence, locales. * Top layout, expanded tips. * Minimal 'tasks' language. * Adjust announcement positioning/dimensions. * Fix tests, defaults. * Fix translations. * Compromise-compromise. * Final tweaks. * More tweaks. * More tweaks. * Issues fix. * Update translations, remove debug. * Fix transparency issue. --------- Co-authored-by: hannesrudolph commit de22566ea299246afae1e19e9ca174d953539756 Author: Tony Zhang <157202938+zhangtony239@users.noreply.github.com> Date: Sat Apr 26 08:13:13 2025 +0800 Fix: word wrapping in Roo message title (#2948) commit e3d55a36ac309ee42d9f4f9e4a85aca25d38248e Author: shariqriazz Date: Fri Apr 25 15:49:19 2025 -0700 docs: fix file paths in settings.md (#2930) commit c8b5cdf7b225e5064e530adfba0b6cf9ffe89bfe Author: Chris Estreich Date: Fri Apr 25 15:24:03 2025 -0700 Omit reasoning params for non-reasoning models (#2932) commit cb29e9d56f8cdccb7ad0c1820021f2946817aa92 Author: Chris Estreich Date: Fri Apr 25 15:23:25 2025 -0700 Remove ModelInfo objects from settings (#2939) commit a354c01b0e71001f7d90f86ac7a4688056c97fbf Author: Matt Rubens Date: Fri Apr 25 16:06:50 2025 -0400 Add Boomerang as a default mode (#2934) * Add Boomerang as a default mode * Rename boomerang, add emoji commit 547874eed788f21fe4fdf674eaecd34491bf3c3b Author: Matt Rubens Date: Fri Apr 25 16:04:38 2025 -0400 Revert "Fix: Preserve editor state and prevent tab unpinning during diffs" (#2956) Revert "Fix: Preserve editor state and prevent tab unpinning during diffs (#2…" This reverts commit c2dd743aeb2c8e22818e7a5880ce61d26c6ea1fb. commit 06db54730877052b38538aa3be98057065ee07a8 Author: Chris Estreich Date: Fri Apr 25 10:32:35 2025 -0700 Use a WASM-based tiktoken implementation (#2859) * Use a WASM-based tiktoken implementation * Clean up imports commit 7e76736e13787425160c87133e038f147aac04cd Author: pugazhendhi-m <132246623+pugazhendhi-m@users.noreply.github.com> Date: Fri Apr 25 17:06:25 2025 +0530 Updates default model for Unbound (#2944) * Updates default model for Unbound * Adds changeset --------- Co-authored-by: Pugazhendhi commit 586e43bd557db9932bb3d0ba8b9232c049318c93 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Thu Apr 24 16:41:26 2025 -0700 Changeset version bump (#2926) * changeset version bump * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Chris Estreich commit 0dfbae64f3d8f9d90b5ea1fdea647f66b5c27341 Author: Chris Estreich Date: Thu Apr 24 16:34:22 2025 -0700 Allow users to toggle Gemini caching on / off for OpenRouter (#2927) commit 5c2511e3554c0514db3eeb7ee06ee04c8a59c378 Author: KJ7LNW <93454819+KJ7LNW@users.noreply.github.com> Date: Thu Apr 24 16:28:09 2025 -0700 feat: compress terminal output with backspace characters (#2907) Follow-up to #2562 adding support for backspace character compression. Optimizes terminal output by handling backspace characters similar to carriage returns, improving readability of progress spinners and other terminal output that uses backspace for animation. - Added processBackspaces function using efficient indexOf approach - Added comprehensive test suite for backspace handling - Integrated with terminal output compression pipeline Signed-off-by: Eric Wheeler Co-authored-by: Eric Wheeler commit 23aa3b636b040dd782f49eb45885791eb58162c6 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu Apr 24 19:26:00 2025 -0400 Update contributors list (#2903) docs: update contributors list [skip ci] Co-authored-by: cte commit a3f1a3f3ad33dfd3f27924fc40ae0456b0438e7d Author: Chris Estreich Date: Thu Apr 24 14:20:09 2025 -0700 Gemini caching improvements (#2925) commit 7f99c0691e4822b5efebe7a9aeb286efe3260d2f Author: asychin Date: Fri Apr 25 03:03:30 2025 +0700 feat/add-russian-lang (#2909) * rus * sad * fix lang * Add ru to translate mode instructions * Add ru to evals types * Add link to READMEs * Bring back smart quotes * Add prompt caching translations --------- Co-authored-by: Matt Rubens commit b75379bed39148562bbca1b55b2796072b517b64 Author: Chris Estreich Date: Thu Apr 24 12:29:36 2025 -0700 Improve OpenRouter model fetching (#2922) commit ad4782b766e58308c4927438528a4c0d7af358af Author: Chris Estreich Date: Thu Apr 24 12:28:43 2025 -0700 Add an option to enable prompt caching (#2924) commit 31600ed3c9b9decb87e9ddff81b0e6177c07a257 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Thu Apr 24 09:27:32 2025 -0700 Changeset version bump (#2919) * changeset version bump * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Chris Estreich commit d46a9b484c739bfa0bc25495d9561439f2a4cfce Author: Chris Estreich Date: Thu Apr 24 09:24:56 2025 -0700 v3.14.1 (#2920) commit fb9183620394ffb9567c1bfba3fe6caa83af94f1 Author: Chris Estreich Date: Thu Apr 24 09:15:56 2025 -0700 Revert Gemini caching, fix OR supports cache issue (#2918) commit 9b739658678e5f2aa1bcd8beb6c69bc635704bb4 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Wed Apr 23 21:51:24 2025 -0700 Changeset version bump (#2877) * changeset version bump * Update CHANGELOG.md * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens commit 2e9b1f1824c64e532f9bf4281f8676ff59d08857 Author: Matt Rubens Date: Thu Apr 24 00:44:24 2025 -0400 v3.14.0 (#2902) commit 37a8a442ac0ff49f4d8bf702919e001401918922 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu Apr 24 00:31:27 2025 -0400 Update contributors list (#2871) docs: update contributors list [skip ci] Co-authored-by: mrubens commit b5ffaf1ba2f6561f714f666397e1e942a1664857 Author: Yikai Liao Date: Thu Apr 24 12:22:47 2025 +0800 Fix Terminal Carriage Return Handling for Correct Progress Bar Display (#2562) * fix(terminal): Ensure correct handling of carriage returns for progress bars This commit refines the tests for `TerminalProcess` to ensure the correct interpretation of terminal output containing carriage returns (`\\r`), which is essential for properly handling dynamic elements like progress bars (e.g., `tqdm`). - Validated the `processCarriageReturns` method's behavior in simulating terminal line overwrites caused by `\\r`. - Corrected the expectation in the `handles carriage returns in mixed content` test to accurately reflect the method's output (final line content + preserved escape sequences), confirming the logic works as intended for progress-bar-like updates. - Fixed a minor Jest `toBe` syntax error in a related test case. - Suppressed an expected `console.warn` in the non-shell-integration test for cleaner logs. By ensuring `processCarriageReturns` is correctly tested, we increase confidence that the component responsible for pre-processing terminal output handles progress bars appropriately before the output is potentially used elsewhere (e.g., sent to an LLM). * fix(test): Make TerminalProcess integration test reliable This commit fixes the flaky test case `integrates with getUnretrievedOutput to handle progress bars` in `TerminalProcess.test.ts`. The test previously failed intermittently due to: 1. Relying on a fixed `setTimeout` duration to wait for asynchronous stream processing, which created a race condition. 2. Incorrectly assuming that `await terminalProcess.run(...)` would return the final output directly via its resolved value. The fix addresses these issues by: - Removing the unreliable intermediate check based on `setTimeout`. - Modifying the test to correctly obtain the final output by listening for the `completed` event emitted by `TerminalProcess`, which is the intended way to receive the result. This ensures the test accurately reflects the behavior of `TerminalProcess` and is no longer prone to timing-related failures. * Add changeset for terminal carriage return fix * Implement terminal compress progress bar feature This commit introduces a new feature to compress terminal output by processing carriage returns. The `processCarriageReturns` function has been integrated into the `Terminal` class to handle progress bar updates effectively, ensuring only the final state is displayed. Additionally, the `terminalCompressProgressBar` setting has been added to the global settings schema, allowing users to enable or disable this feature. Tests have been updated to validate the new functionality and ensure correct behavior in various scenarios. A Benchmark is also added to test the performance. Not that there is still no i18n support for this. * Add i18n support for compressProgressBar setting in multiple languages * Optimize processCarriageReturns function for performance and multi-byte character handling This commit enhances the `processCarriageReturns` function by implementing in-place string operations to improve performance, especially with large outputs. Key features include: - Line-by-line processing to maximize chunk handling. - Use of string indexes and substring operations instead of arrays. - Single-pass traversal of input for efficiency. - Special handling for multi-byte characters to prevent corruption during overwrites. Additionally, tests have been updated to validate the new functionality, ensuring correct behavior with various character sets, including emojis and non-ASCII text. Highly Density CR case is added to Benchmark * slight performance improvement by caching several variable * Optimize multi-byte character handling in processCarriageReturns Refactor the logic within the `processCarriageReturns` function to simplify the detection of partially overwritten multi-byte characters (e.g., emojis). Removed redundant checks and clarified the conditions for identifying potential character corruption during carriage return processing. This improves code readability and maintainability while preserving the original functionality of replacing potentially corrupted characters with a space. Also enforced consistent use of semicolons for improved code style. * docs: standardize carriage return (\r) and line feed (\n) terminology Improve code clarity by consistently adding escape sequence notation to all references of carriage returns and line feeds throughout documentation and tests. This makes the code more readable and avoids ambiguity when discussing these special characters. * feat: Improve terminal output processing clarity and settings UI - Add detailed comments to `processCarriageReturns` explaining line feed handling. - Relocate `terminalCompressProgressBar` setting below `terminalOutputLineLimit` for better context in UI. * Fix: Compress Progress Bar Setting Checkbox --------- Co-authored-by: Matt Rubens commit 3b65023d0b7e1470c46788f070519a8b16a41be9 Author: axb Date: Thu Apr 24 12:12:07 2025 +0800 feat(diff): improve progress indicator for apply_diff tool (#2758) Add animated dots to progress indicator based on content length Optimize when progress updates are shown (every 10 characters) Move searchBlockCount calculation inside conditional blocks Skip unnecessary ask operations when toolProgressStatus is empty commit c228e638b993ab844a876008e694513f221f7fba Author: Hannes Rudolph Date: Wed Apr 23 15:43:36 2025 -0600 Update insert_content tool description for clarity and detail (#2892) commit 41db2cad4312378ff9d87e52e177772108ad7727 Author: Hannes Rudolph Date: Wed Apr 23 15:42:59 2025 -0600 Update search_and_replace tool description for clarity and detail (#2891) commit a53f604e3ae663a714b8cfaaf9ee3287af47b80f Author: Matt Rubens Date: Wed Apr 23 16:46:52 2025 -0400 Disable OpenRouter Gemini caching for now (#2890) commit 03925d25b0cae7ccdfe46fb3ed1b1669ab4f8b36 Author: Chris Estreich Date: Wed Apr 23 13:26:59 2025 -0700 Fix code cli flag in evals (#2889) commit 1543713c32895adb3ea5c39f9e4984b74cc80a77 Author: Chris Estreich Date: Wed Apr 23 13:24:22 2025 -0700 Don't immediately show an model ID error when changing API providers (#2888) commit 2c40224f6f86445d4a7ebb83cb71a37b8960b656 Author: Chris Estreich Date: Wed Apr 23 12:20:13 2025 -0700 Fix task size cache TTL (#2887) commit 72962b3c763e2ef06cbbbe40e62eb193985aa2dc Author: Chris Estreich Date: Wed Apr 23 12:09:24 2025 -0700 Split api and chat message persistence into a separate module (#2866) commit 230e953e5d62d4abee3c713dbb5dc4c2b50362cc Author: Chris Estreich Date: Wed Apr 23 11:42:22 2025 -0700 Throttle calls to calculate task folder size (#2885) commit b421636a2c6c1c5fbf9f2e035fa5d67847c6e162 Author: Chris Estreich Date: Wed Apr 23 11:41:06 2025 -0700 Properly hide cache section of task header (#2884) commit a08461a6552ad3ddda6102fd4471fedd64f10465 Author: Chris Estreich Date: Wed Apr 23 11:35:50 2025 -0700 Gemini prompt caching (#2827) commit 31d4b656d06f90d826238f18854161c32ddcbc6e Author: Chris Estreich Date: Wed Apr 23 11:21:35 2025 -0700 Package material icons in vsix (#2882) * Package material icons in vsix * Add changeset commit a77be28a95dd1049e59eb577e3a55239c98cfe07 Author: Chris Estreich Date: Wed Apr 23 11:16:20 2025 -0700 Use formatLargeNumber on token counts in task header (#2883) * Format large numbers * Add changeset commit 970ccd7879c6f6df915dfab05cc7bfecd448a0f7 Author: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Wed Apr 23 12:21:32 2025 -0500 feat: add other useful variables to the custom system prompt (#2879) commit 4606e9a6cca8c838044b3c3a2b0fd5c419a12d41 Author: Dicha Zelianivan Arkana <51877647+elianiva@users.noreply.github.com> Date: Wed Apr 23 22:57:50 2025 +0700 fix(chat): better loading feedback (#2750) * fix(chat): better loading feedback * fix(chat): missing loading aria role Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * refactor(chat): use vscode loading for more consistency --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> commit 80912d0919005afab6eb101e3358d19291e595aa Author: Matt Rubens Date: Wed Apr 23 09:47:19 2025 -0400 v13.3.3 (#2876) commit a9ca17717c438ffffb22e1776ce20f9e1a85ca7d Author: Chris Estreich Date: Wed Apr 23 06:45:57 2025 -0700 OpenRouter Gemini caching (#2847) * OpenRouter Gemini caching * Fix tests * Remove unsupported models * Clean up the task header a bit * Update src/api/providers/openrouter.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Remove model that doesn't seem to work --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> commit e53d299acf922395acf7b7b99a9a0230580dd4d9 Author: mlopezr Date: Wed Apr 23 15:36:50 2025 +0200 Allow Amazon Bedrock Marketplace ARNs (#2874) * Update validate.ts Allow ARNs from Bedrock Marketplace, which are different because models are deployed using SageMaker Inference behind the scenes. * Update bedrock.ts Allow ARNs from Bedrock Marketplace, which are different because models are deployed using SageMaker Inference behind the scenes. commit 844753e0d99e1927a5ec6fec14abe2dcecc1334f Author: Dominik Oswald <6849456+d-oit@users.noreply.github.com> Date: Wed Apr 23 15:34:32 2025 +0200 Remove unnecessary cost calculation from vscode-lm.ts (#2875) * feat: Removed unnecessary cost calculation * Update vscode-lm.ts --------- Co-authored-by: Matt Rubens commit 74faacd69d9d767d0fb6ef9d0c9d2ba01957838b Author: Wojciech Kordalski Date: Wed Apr 23 10:42:06 2025 +0200 FakeAI "controller" object must not be copied (#2463) The FakeAI object passed by the user must be exactly the same object that is passed to FakeAIHandler via API configuration. Unfortunatelly, as the VSCode global state is used as configuration storage, we lose this property (VSCode global state creates copies of the object). Also the class of the stored object is lost, so methods of the object are unavailable. Therefore, we store the original objects in global variable and use ID field of FakeAI object to identify the original object. commit e403a96e668da8962e5d0ef03ad328f153e279a6 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed Apr 23 03:14:08 2025 -0400 Update contributors list (#2867) docs: update contributors list [skip ci] Co-authored-by: mrubens commit 01a7a66ca0d82acf2843f942713edb2551920f0f Author: Trung Dang Date: Wed Apr 23 14:13:04 2025 +0700 feat: add `injectEnv` util, support env ref in mcp config (#2679) * feat: support environment variables reference in mcp `env` config * tests(src/utils/config): add test for `injectEnv` * fix(injectEnv): use `env == null` and `??` check instead of `!env`, `||` * refactor: remove unnecessary type declare * chore!: simplify regexp, remove replacement for env vars with dots commit 3df9eac5b7798fe6426a79ccceff9034d1406240 Author: Dicha Zelianivan Arkana <51877647+elianiva@users.noreply.github.com> Date: Wed Apr 23 14:09:29 2025 +0700 fix(mention): conditionally remove aftercursor content (#2732) commit 33ee5d6c34ce324441beecafa55dfea98eaa829b Author: hongzio <11085613+hongzio@users.noreply.github.com> Date: Wed Apr 23 16:06:48 2025 +0900 Fix: focusInput open roo code panel (#2626) (#2817) * Fix: focusInput open roo code panel (#2626) * Fix: `roo-cline.focusInput` open roo code panel * fixup! Fix: focusInput open roo code panel (#2626) commit 3a5913ffca4db48f3abb60920ff292690eec4ff8 Author: Alfredo Medrano Date: Wed Apr 23 01:05:47 2025 -0600 Bugfix/fix vscodellm model information (#2832) * feat: initialize VS Code Language Model client in constructor * feat: add VS Code LLM models and configuration * feat: integrate VS Code LLM models into API configuration normalization * Fix tests --------- Co-authored-by: Matt Rubens commit c2dd743aeb2c8e22818e7a5880ce61d26c6ea1fb Author: seedlord Date: Wed Apr 23 08:45:35 2025 +0200 Fix: Preserve editor state and prevent tab unpinning during diffs (#2857) - Maintains editor view column state when closing and reopening files during diff operations, ensuring tabs stay opened in their original position. - Prevents closing the original editor tab when opening the diff view, preserving pinned status when applying changes via write_to_file or apply_diff. - Updates VSCode workspace launch flag from -n to -W for compatibility. commit 3d129e8a8900954a1155756f3f948f9d267b4341 Author: Hannes Rudolph Date: Wed Apr 23 00:35:10 2025 -0600 fix: allow opening files without workspace root (#1054) * fix: allow opening files without workspace root The openFile function in open-file.ts was requiring a workspace root to be present, which prevented opening global files (like MCP settings) when no workspace was open. Modified the function to handle absolute paths without this requirement. Previously, trying to open MCP settings in a new window without a workspace would error with "Could not open file: No workspace root found". Now the function properly handles both workspace-relative and absolute paths, allowing global settings files to be accessed in any context. Changes: - Removed workspace root requirement in openFile - Added fallback for relative paths when no workspace is present * fix: update openFile function to use provided path without modification --------- Co-authored-by: Roo Code Co-authored-by: Matt Rubens commit 0f64849542e50d8b322c64966ec6419e684f4143 Author: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Wed Apr 23 00:09:14 2025 -0500 feat: allow variable interpolation into the custom system prompt (#2863) * feat: allow variable interpolation into the custom system prompt * fix: allow the test to pass on windows by using the path module commit c6f91a3b2f8f70373e3a3a478fb06f9a99611946 Author: System233 Date: Wed Apr 23 03:40:33 2025 +0800 Fix error message not showing after canceling API request (#2845) commit 1a376c262ef20b694f84093119b5c50b8eb1762e Author: Dicha Zelianivan Arkana <51877647+elianiva@users.noreply.github.com> Date: Wed Apr 23 02:39:45 2025 +0700 [DRAFT] feat(menu): use material icons for files and folders (#2739) feat(menu): use material icons for files and folders commit 4f5b04b6c9f5deb8190649eb80491f4f97a21e87 Merge: 2c6ef8a1 62d55893 Author: JB Brown Date: Tue Apr 22 12:30:06 2025 -0700 Merge pull request #4 from Smartsheet-JB-Brown/hobbsessr/fix-marketplace-redraw-issue Hobbsessr/fix marketplace redraw issue commit 62d5589397449ea1aeaba480cc09142dff03bc6a Author: HobbesSR <20545418+HobbesSR@users.noreply.github.com> Date: Tue Apr 22 12:16:01 2025 -0500 Fix marketplace tab switching and redraw issue that occurred every 30 seconds commit 0d561f8a27e592c9ef917649dc47822a86dd637b Author: System233 Date: Tue Apr 22 22:12:29 2025 +0800 Fix redundant 'TASK RESUMPTION' prompts (#2842) commit 8ac911244c6b0446fc14059b1444ecaa78d44136 Author: System233 Date: Tue Apr 22 22:10:34 2025 +0800 Fix user feedback not being added to conversation history in API error state (#2844) Fix user feedback not being added to conversation history in the API error state commit f1c79759a0aae136a27aa48c4a2eff509d01cecf Author: Matt Rubens Date: Tue Apr 22 00:15:46 2025 -0400 Add line wrapping to MCP arguments (#2831) commit 3e2d20f37c47cd0a9ba53d15db97aa0207d40908 Author: Matt Rubens Date: Mon Apr 21 22:57:58 2025 -0400 Search and replace fixes (#2830) * Allow replacing with an empty string * Visual cleanup commit 8b5d48013e0724bed5b0cc0acb663af50b8aa2bf Author: Matt Rubens Date: Mon Apr 21 22:39:39 2025 -0400 Fix MCP hub lookup during view transition (#2829) commit b6ea9d14655d27ce9a92dd5ecce275bb291600cf Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon Apr 21 21:22:54 2025 -0400 Update contributors list (#2803) docs: update contributors list [skip ci] Co-authored-by: mrubens commit ff5430a95c5d86fa1c51cd351ced390a7e55586c Author: Matt Rubens Date: Mon Apr 21 21:18:25 2025 -0400 Try adding better errors for write_to_file truncated output (#2821) commit f06567d579cf180d8d9ccefd349f3225e3589605 Author: Sam Hoang Van Date: Tue Apr 22 03:49:27 2025 +0700 Feat/improve insert block content (#2510) * refactor: enhance insertGroups and insertContentTool for better handling of insertion operations * refactor: simplify insert_content tool - Remove operations-based implementation in favor of single line insertion - Update parameters from operations to line and content - Simplify insertion logic and error handling - Update tool description and documentation - Remove XML parsing for operations - Clean up code and improve error messages * refactor: remove insert_content experiment and related tests * Remove the append_to_file tool * Improvements to chat row and instructions --------- Co-authored-by: Matt Rubens commit 80b298492cf2a755deb027583bc291d9b00b83fb Author: Sam Hoang Van Date: Tue Apr 22 02:25:03 2025 +0700 improve search and replace tool (#2764) * Refactor search and replace tool * feat(search-replace): enhance search/replace tool UI and messaging Refactor search/replace tool message structure for better consistency Add dedicated UI component for displaying search/replace operations Add i18n support for search/replace operations in all supported languages Improve partial tool handling in searchAndReplaceTool * Remove search_and_replace experiment and related references commit 61e23cccb6f2c8160a2ec42c79cf05466b125542 Author: Chris Estreich Date: Mon Apr 21 11:04:35 2025 -0700 Record tool use errors encountered during eval runs (#2816) commit a244a9dc2156f79bb22eb7cd19e4a3edc2c74ff5 Author: Matt Rubens Date: Mon Apr 21 13:54:02 2025 -0400 Fix a bad search/replace when moving tools into their own files (#2815) commit b955dbc1fcb2cd11a52f8d8968248441acd967a2 Author: Daniel Trugman Date: Mon Apr 21 16:56:02 2025 +0100 Requesty models behind api key (#2813) * Don't fetch Requesty models on startup, only when opening settings * Provide api key when fetching models commit f1c3edeb75a31e69a4f23bde0fb3ccf2cdbb09b4 Author: Felix NyxJae <52313587+NyxJae@users.noreply.github.com> Date: Mon Apr 21 21:27:24 2025 +0800 Fix: Improve drag-and-drop and SSH path handling (#2808) * Fix: Correct path handling for dragged files on Windows * Fix: Improve drag-and-drop and SSH path handling commit 29137fbfec190005e31b704f00b98e6640e7e2e3 Author: Matt Rubens Date: Mon Apr 21 00:24:52 2025 -0400 Revert changes on omitted line count in write_to_file (#2807) commit 2c6ef8a1849563adafd7814151cc4134c4e3fd0d Author: Smartsheet-JB-Brown Date: Sun Apr 20 20:56:53 2025 -0700 allow relative path from pacakge items to outside of packages directory commit 19df13c760d8ee8bd5b9b6020127420b9f300b46 Author: Matt Rubens Date: Sun Apr 20 23:28:19 2025 -0400 Add a warning display when a system prompt override is active (#2804) commit d2e5019f885bedad900146daad757ee3c268bdcb Author: Dicha Zelianivan Arkana <51877647+elianiva@users.noreply.github.com> Date: Mon Apr 21 09:04:23 2025 +0700 fix(icons): use geometricPrecision to avoid blurriness (#2756) commit 47230d5a5c5acca6f13e16078db80cc28c7fc1bb Author: Dicha Zelianivan Arkana <51877647+elianiva@users.noreply.github.com> Date: Mon Apr 21 08:46:01 2025 +0700 fix(action): handle edge case for add to context action (#2780) commit 612b9481932311175da81208e3ab86b9901499a5 Author: Sacha Sayan Date: Sun Apr 20 21:38:08 2025 -0400 Refactor: Use path aliases in webview source files. (#2801) * Refactor: Use path aliases in webview source files. * Add module resolution to root jest config. * Add tests. Broken import. Another broken import. Broken test. commit cf54c7d82001ba1a1d358211385295fa29cce0a2 Author: Sacha Sayan Date: Sun Apr 20 21:19:42 2025 -0400 Quickfix: Change cloud-download icon to more appropriate desktop-download icon. (#2802) commit eeb73c3c0663d3535d6c1f80ba823770bd9c1f9b Author: Nico Bihan Date: Sat Apr 19 23:30:20 2025 -0500 Adds Gemini 2.5 Flash "thinking" model to Vertex AI Provider (#2794) commit ed102d1850d5948f75e497dd154a8197e7ff7ccb Author: Matt Rubens Date: Sat Apr 19 15:43:27 2025 -0400 Remove the strict line bounds check from the diff (#2790) commit 2205606118a624998885049779f9dd5c9b8c8fd3 Author: Matt Rubens Date: Sat Apr 19 14:26:35 2025 -0400 Remove globby as it's no longer used (#2788) commit 5d0aa20f9744c5943e2e207e4b77c6c3bdb00030 Author: Matt Rubens Date: Sat Apr 19 14:13:45 2025 -0400 Switch list files from globby to ripgrep (#2689) * Switch list files from globby to ripgrep * PR feedback * PR fix commit c5f48a8bc68a67fbe01e82bf9a8b04449eb5c69e Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Fri Apr 18 21:14:30 2025 -0700 Changeset version bump (#2777) * changeset version bump * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Chris Estreich commit 8c18727f9a16f42799f6bb83e42b61382f1d9280 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri Apr 18 21:13:43 2025 -0700 Update contributors list (#2779) docs: update contributors list [skip ci] Co-authored-by: cte commit e74b69dfbc29e258c9ea41676cf7fa6350e63b4c Author: Chris Estreich Date: Fri Apr 18 21:01:21 2025 -0700 v3.13 announcement (#2778) * v3.13 announcement * Update README.md * Update lastShownAnnouncementId * Tweak copy commit c10600e35445e048c9b8302be51df502b5e886da Author: Chris Estreich Date: Fri Apr 18 19:25:42 2025 -0700 Pass baseURL to Gemini API if googleGeminiBaseUrl is set (#2776) commit b8b90737fa707eaf4f0512fbf1a3d5a6320571be Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Fri Apr 18 17:39:24 2025 -0700 Changeset version bump (#2768) * changeset version bump * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Chris Estreich commit 5eaf8ba876a877a75c4c7b5259ae6e46badaec41 Author: Smartsheet-JB-Brown Date: Fri Apr 18 16:48:28 2025 -0700 fix regex in response to security scan concern in CI build commit 130493e43da06d72fc4d602654d7ebee062cfaa5 Author: Smartsheet-JB-Brown Date: Fri Apr 18 15:25:35 2025 -0700 fix renaming bug for locales commit 2a5d472f0d6da33db69e12e4835c06bf61461b2c Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri Apr 18 15:14:38 2025 -0700 Update contributors list (#2770) docs: update contributors list [skip ci] Co-authored-by: cte commit d5fe876c9bdae660cd3cb2645773f051b7876b77 Author: Chris Estreich Date: Fri Apr 18 14:56:01 2025 -0700 v3.13.1 (#2774) commit 1a05a4190f20e315d81f6fc3bd95d09e317a5213 Merge: a122dc74 96ff9fc3 Author: Smartsheet-JB-Brown Date: Fri Apr 18 14:52:12 2025 -0700 Merge branch 'main' into jbbrown/marketplace commit a122dc7465842fde16d870106b50028824202dcc Author: Smartsheet-JB-Brown Date: Fri Apr 18 11:08:21 2025 -0700 fix failing tests from state management changes commit 96ff9fc380ee1f627ede849b8589a422f401fb3a Author: Chris Estreich Date: Fri Apr 18 14:43:27 2025 -0700 Fix pricing for Gemini 2.5 Flash (Thinking) (#2773) * Fix pricing for Gemini 2.5 Flash (Thinking) * Looks like it's actually $3.50 * We aren't honoring custom thinking token budgets on Vertex yet commit f6e4e3504f76f34c6381972500a4e64a83ac4780 Author: Chris Estreich Date: Fri Apr 18 14:43:04 2025 -0700 Move executeCommand out of Cline and add telemetry for shell integration errors (#2771) commit 0bb5ec18c56b0f8bc28579c3b9c028372b6b23d5 Author: Sacha Sayan Date: Fri Apr 18 16:43:03 2025 -0400 UI: Auto-approve toggle styling tweak. (#2769) commit 5abea50cf1ba25a1984aa643c0ebbc269a2df7d2 Author: Chris Estreich Date: Fri Apr 18 12:26:17 2025 -0700 Support Gemini 2.5 Flash thinking (#2752) commit 968e19047bfbe8df117c377b2acd37fe668f811e Author: Smartsheet-JB-Brown Date: Fri Apr 18 10:23:02 2025 -0700 rebrand to Marketplace commit 6772306ade0aa93d6905667a0bf92c57525f9f2c Author: Felix NyxJae <52313587+NyxJae@users.noreply.github.com> Date: Fri Apr 18 22:29:32 2025 +0800 Fix: Correct path handling for dragged files on Windows (#2753) commit b3065d2ab3fef48b8ba6d663aba1d3159b140b54 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri Apr 18 03:09:14 2025 -0400 Update contributors list (#2715) docs: update contributors list [skip ci] Co-authored-by: mrubens commit 31656d9b16c3d0d2dd3789cb93f039b37a26b277 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Thu Apr 17 23:47:41 2025 -0700 Changeset version bump (#2716) * changeset version bump * Update CHANGELOG.md * Update package.json * Update package-lock.json --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens commit c329b4509f3d1c98baf13ee89102a8df10aa90ff Author: Matt Rubens Date: Fri Apr 18 02:29:13 2025 -0400 v3.12.4 (#2745) commit 06882f56872729bc5cb09862e2b3fb548e491f04 Author: Matt Rubens Date: Fri Apr 18 02:23:55 2025 -0400 Don't break if an end_line is passed into a diff (#2743) commit 87af3b3424b9e6a36bd7b93689dbb80003a8ccd4 Author: Chris Estreich Date: Thu Apr 17 22:21:14 2025 -0700 Record tool usages in the `Cline` object, and persist them in the db for evals (#2729) commit 887d2ac330be25d44631f0b4c9dad4a2072c5fde Author: Smartsheet-JB-Brown Date: Thu Apr 17 21:52:31 2025 -0700 fix missing translations commit 9e2476c667052a81400f43a595ff31a97996ad71 Author: Smartsheet-JB-Brown Date: Thu Apr 17 21:47:50 2025 -0700 attempt to fix failing test on windows in ci build commit c0f615bca77f06de54f173d6d1995c618084464a Author: Smartsheet-JB-Brown Date: Thu Apr 17 21:40:26 2025 -0700 more memory pressure work commit b5a77e34a4c43af949a70e0ab237dad0ccf064df Author: Nico Bihan Date: Thu Apr 17 23:35:06 2025 -0500 Fixes maximum token limit for Gemini provider 2.5 pro exp (#2737) Corrects the maximum token limit for the "gemini-2.5-pro-exp-03-25" model, ensuring accurate configuration. commit fffebf1a2e6f576f0bd83fabbec9a3f3994b3403 Author: Matt Rubens Date: Fri Apr 18 00:28:55 2025 -0400 Remove experiment for append block (#2738) * Remove experiment for append block * Fix bugs in experiment lookups commit c84343dbbeffb21dacfb69ff5dbaa5c3427366a9 Author: Smartsheet-JB-Brown Date: Thu Apr 17 21:02:15 2025 -0700 reduce memory use during MetaDataScan commit b05c3100bd29e2adebf96d04b264862b9051e724 Author: Matt Rubens Date: Thu Apr 17 23:25:35 2025 -0400 Fix context window bar color (#2733) * Fix context window bar color * Make task header cost badge match the other ones * Fix test commit 86636526bd467fb06d8c08be311b7f09b5c2d681 Author: Matt Rubens Date: Thu Apr 17 23:25:23 2025 -0400 Update the style of the suggestions (#2734) * Update the style of the suggestions * Cleanup commit 422aed61b7d9307f739820ac5f72d6b346388aac Author: Smartsheet-JB-Brown Date: Thu Apr 17 20:21:16 2025 -0700 memory optimization and revert test command in package.json to main commit bea36c897725403d4072bb160d98a32eabc8cb82 Author: Nico Bihan Date: Thu Apr 17 22:19:40 2025 -0500 Gemini 2.5 Flash Preview fix Max Tokens Count (#2735) Gemini 2.5 Flash Preview fix Max Tokens commit 93b96c3253284c730c1f5a1ed51d751a303a9ee0 Author: Smartsheet-JB-Brown Date: Thu Apr 17 19:53:24 2025 -0700 attempt at memory cleanup commit 3cc3dab9e5785c202faab185a4420b74a9797f43 Author: Smartsheet-JB-Brown Date: Thu Apr 17 19:28:50 2025 -0700 change metadatascanner to be more memory efficient commit b7f45f7870d7253f5e00f8eea98a55bc06a0288b Author: Smartsheet-JB-Brown Date: Thu Apr 17 19:23:29 2025 -0700 reduce memory usage - avoid deep copy commit a2d7f1941827712eee74c121e721e17c19b647fc Author: Smartsheet-JB-Brown Date: Thu Apr 17 17:30:53 2025 -0700 round 5 commit 1cb77542e1af3c12b389cb0feb596ba5bc171a0a Author: Smartsheet-JB-Brown Date: Thu Apr 17 17:25:28 2025 -0700 round 4 commit 8a9240000c485a274cb33326b8c102d5f36f0c92 Author: Smartsheet-JB-Brown Date: Thu Apr 17 17:20:14 2025 -0700 round 3 of locale CI fixes commit 724a2f2350aaa45e5ce8e57f8ea6e350c09be352 Author: Smartsheet-JB-Brown Date: Thu Apr 17 17:13:02 2025 -0700 another round of locale bugs in the CI build but not local commit 38d96fa4fb4976369c1a4070e561d8031d23d83d Author: Smartsheet-JB-Brown Date: Thu Apr 17 17:06:07 2025 -0700 try to fix locale bugs that happen in CI build but not locally commit 78edc21f2e279907cf1cdc9eb1c6b133b7a7af30 Merge: 1aea6b4d 3c937c38 Author: Smartsheet-JB-Brown Date: Thu Apr 17 16:53:19 2025 -0700 merge main commit 1aea6b4d9b367814b96f8cfc156ac39dded27a9f Merge: 4f8799f1 92c55d54 Author: Smartsheet-JB-Brown Date: Thu Apr 17 15:54:34 2025 -0700 Merge hobbessr/metadatascanner-test-path-recursion-fix commit 92c55d542e67c927ac3f920b3deb53d99336c6ef Author: HobbesSR <20545418+HobbesSR@users.noreply.github.com> Date: Thu Apr 17 17:38:07 2025 -0500 Fix the MetaDataScanner.test.ts infinite recursion in its mock setup commit 3c937c38275a994913cadca2692ebc4d03d23e23 Author: Chris Estreich Date: Thu Apr 17 15:28:11 2025 -0700 Task header theme fixes (#2721) commit b077267b4a9b8762174d559efe0f198c5ce5e225 Author: Smartsheet-JB-Brown Date: Thu Apr 17 15:25:11 2025 -0700 fixes image support in bedrock. regression from prompt cache implementation (#2723) fixes image support in bedrock. regression created during prompt caching implementation commit d86d601104c94d408f7316b7497e3bc9862ffb19 Author: Matt Rubens Date: Thu Apr 17 16:39:00 2025 -0400 Add gemini 2.5 flash preview (#2720) commit 4f8799f1697b7e9a9aa1d635afeb4a9aab5f426e Author: Smartsheet-JB-Brown Date: Thu Apr 17 13:32:08 2025 -0700 fix localization bugs commit 471caff000984f8a06e22776f9daadf1cfc2dee7 Author: Chris Estreich Date: Thu Apr 17 13:31:48 2025 -0700 Clean up types related to tools (#2719) commit 026091e4323ce5e40cced4c64608053ea6870f98 Author: Hannes Rudolph Date: Thu Apr 17 14:18:50 2025 -0600 Fix filename format in downloadTask function for markdown export (#2717) * Fix filename format in downloadTask function for markdown export * Update src/integrations/misc/export-markdown.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> commit 1853ebaa9c4f70eec48daa2b68e69acfe2fe264c Author: Smartsheet-JB-Brown Date: Thu Apr 17 12:59:13 2025 -0700 add sourceUrl to support packages hosted external to the package manager repo commit 1e0e01b9f3d1d0955e14901393920c2da9833c8b Author: Sam Hoang Van Date: Fri Apr 18 02:58:22 2025 +0700 feat: add append_to_file tool for appending content to files (#2712) - Implemented the append_to_file tool to allow users to append content to existing files or create new ones if they do not exist. - Updated the rules and instructions to include the new tool. - Added tests for the append_to_file functionality, covering various scenarios including error handling and content preprocessing. - Enhanced the experiment schema to include the new append_to_file experiment ID. - Updated relevant interfaces and types to accommodate the new tool. - Modified the UI to display the append_to_file tool in the appropriate sections. commit d3c37eaec8456f323f2beae971061c0dc9df8e85 Author: Chris Estreich Date: Thu Apr 17 10:23:19 2025 -0700 Add missing translation (#2714) * Add missing translation * More translation fixes commit 159e4005e56fc87f5e634d67688f01b22bfbd256 Author: Sacha Sayan Date: Thu Apr 17 12:54:55 2025 -0400 UI Glam Sesh: Makeover for TaskHeader, ChatView, HistoryPreview... (#2701) * - UI Glam Session -> Makeover for TaskHeader, ChatView, HistoryPreview, WelcomeView - In particular, display a "no tasks in workspace" message when no tasks are found. - Clean up Inferface Settings (not needed now) on Settings View - Copy updates throughout these areas. * Apply suggestions from code review Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Missing translation string. * Fix test * Fix tests * Improve translations / fix missing strings. * Support xAI for evals (#2703) * Make sure the slash commands only fire if they're the first character (#2702) * Update contributors list (#2675) docs: update contributors list [skip ci] Co-authored-by: mrubens * v3.12.3 (#2710) * Changeset version bump (#2711) * changeset version bump * Updating CHANGELOG.md format * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: R00-B0T Co-authored-by: Matt Rubens * Update translations --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: cte Co-authored-by: Matt Rubens Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Co-authored-by: R00-B0T commit 0736379ad0260c1a17f81ee7cbbed403880b078c Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Thu Apr 17 07:45:43 2025 -0700 Changeset version bump (#2711) * changeset version bump * Updating CHANGELOG.md format * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: R00-B0T Co-authored-by: Matt Rubens commit 94842f21af0c1b969019f6f3cf0f5cb4570ab13d Author: Matt Rubens Date: Thu Apr 17 10:34:32 2025 -0400 v3.12.3 (#2710) commit d3ba74c568683fb012a709283ce93dec6250be56 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu Apr 17 10:26:50 2025 -0400 Update contributors list (#2675) docs: update contributors list [skip ci] Co-authored-by: mrubens commit 511ebb7a9891740770ca61ef3338c3cf88bb6616 Author: Matt Rubens Date: Thu Apr 17 07:55:53 2025 -0400 Make sure the slash commands only fire if they're the first character (#2702) commit 0374436fac1edeb7a03f0e6f5c5994a5627d44c8 Author: Chris Estreich Date: Wed Apr 16 23:08:58 2025 -0700 Support xAI for evals (#2703) commit 876742a8873e18c11e30778ce12aadbf9d010961 Author: Smartsheet-JB-Brown Date: Wed Apr 16 21:41:30 2025 -0700 try another modification to the test script for windows out of memory errors in ci build commit 0c817af0557796b7ce4db067ff4d572c4fe0bb23 Author: Smartsheet-JB-Brown Date: Wed Apr 16 21:20:13 2025 -0700 increase max heap size to try and get windows passing in the CI build commit c27aa9c3701caf53d10f6253ce950ed4ccc199ae Author: Smartsheet-JB-Brown Date: Wed Apr 16 21:11:51 2025 -0700 heap cleanup commit e5556dd115297d61ddde109cfb2e935a7687bed7 Author: Smartsheet-JB-Brown Date: Wed Apr 16 20:46:26 2025 -0700 attempt to incrase node heap size commit b7033133ad744b33f9f98e66b328a1013e4cc7dc Author: Smartsheet-JB-Brown Date: Wed Apr 16 20:36:46 2025 -0700 try a path change to see if it helps the CI build pass in the GitHub environment commit 981c7a1270d5493a99e0f2335e75bc77e094ff88 Author: Smartsheet-JB-Brown Date: Wed Apr 16 20:27:20 2025 -0700 rework git test to see if it helps in the CI build commit 68b0a1843db07818a07f1d391bd247dc146e9140 Author: Smartsheet-JB-Brown Date: Wed Apr 16 20:23:12 2025 -0700 fix knip and missing translation errors commit 30c44ff14c5c090fd8c498299a0c0f3d37fd1176 Author: Matt Rubens Date: Wed Apr 16 23:04:16 2025 -0400 Support dragging and dropping tabs into chat text area (#2698) commit 6e299c73f56bbc43c8eb6bb1597a33fcd7dc56f4 Author: Smartsheet-JB-Brown Date: Wed Apr 16 19:28:39 2025 -0700 update documentation commit 9a24ed46a2b7336ae015d94de10c96debf18ab8e Author: Smartsheet-JB-Brown Date: Wed Apr 16 18:02:38 2025 -0700 allow custom git domains to support internal dns at companies commit c2d840cc47585b8636f3403da87a8e895f09696b Author: Smartsheet-JB-Brown Date: Wed Apr 16 17:08:43 2025 -0700 remove console log statements used to debug tests commit 9c28626f24cab8be4b219983dc66ba34d9cc5efc Author: Smartsheet-JB-Brown Date: Wed Apr 16 16:31:24 2025 -0700 refactor: remove package manager state files commit a4a45329222f301351e64534287c9f3df3ed0d36 Author: Smartsheet-JB-Brown Date: Wed Apr 16 16:06:23 2025 -0700 fix typescript errors preventing succesful build commit ba5af60109027c32fe29936257274d676d42f96a Author: Matt Rubens Date: Wed Apr 16 18:05:04 2025 -0400 Fix diff escaping issues (#2694) * Fix diff escaping issues * Potential fix for code scanning alert no. 75: Double escaping or unescaping Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> commit f04487cd3ead61de22c22cbcce382da06ad5da32 Author: Smartsheet-JB-Brown Date: Wed Apr 16 14:35:39 2025 -0700 All but 1 test passing commit a575d25252069fdb53c717e87e0fed23ccb3d85b Merge: 0caf685b 4bf746d6 Author: Smartsheet-JB-Brown Date: Wed Apr 16 12:43:51 2025 -0700 Merge branch 'main' into jbbrown/package-manager * main: (161 commits) Changeset version bump (#2688) v3.12.2 (#2693) Add support for different reasoning effort (#2692) Add OpenAI o3 & 4o-mini (#2691) Add consecutive mistake count to diff error telemetry (#2687) refactor(context-menu): handle filename display better (#2684) Changeset version bump (#2683) Fix select dropdown styling (#2682) Changeset version bump (#2676) v3.12.0 (#2674) Fix configuration titles (#2672) feat: Add 'roo.acceptInput' command (#2598) Add xAI provider (#2667) Await checkpoint saves (except the initial) (#2665) Safe JSON parse in ChatRow (#2666) feat: Cost Display in Task Header - Suppress Zero Cost Values and Ensure Visibility for Gemini, OpenAI, LM Studio, and Ollama (#2662) test: limit Jest worker count to 40% per suite (#2658) DRY up the auto-approve toggles (#2664) Expose reasoning effort option for reasoning models on OpenRouter (#2483) Better string normalization for diffs (#2659) ... commit 0caf685b4fbcc1d7e3e1ad9d99e5e6ebdac4b063 Author: Smartsheet-JB-Brown Date: Wed Apr 16 12:33:20 2025 -0700 rolled back unintended changes not related or needed to support package manager. first phases of build pass, but fail at type checking on things that don't seem related to what I've done commit 4bf746d63512dbd2933796edce35180ee78112a2 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Wed Apr 16 11:52:22 2025 -0700 Changeset version bump (#2688) * changeset version bump * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens commit 454df52462db044c757d6b84564e4fba6b5a683f Author: Matt Rubens Date: Wed Apr 16 14:47:35 2025 -0400 v3.12.2 (#2693) commit 43668e04298b1f933167a2c6c51c5218fdf76a22 Author: Matt Rubens Date: Wed Apr 16 14:16:51 2025 -0400 Add support for different reasoning effort (#2692) commit 250ea6867a65bc51fd25fca4f3d45589f7f04da9 Author: Peter Dave Hello Date: Thu Apr 17 02:10:10 2025 +0800 Add OpenAI o3 & 4o-mini (#2691) Reference: - https://platform.openai.com/docs/models/o3 - https://platform.openai.com/docs/models/o4-mini - https://openai.com/index/introducing-o3-and-o4-mini/ commit 9d761e23e2cfeb303750dfb056169cb9327c6591 Author: Matt Rubens Date: Wed Apr 16 12:02:49 2025 -0400 Add consecutive mistake count to diff error telemetry (#2687) commit 1d029ed0cbb722eb3e6bad85b9fdd9b522f2d610 Author: Dicha Zelianivan Arkana <51877647+elianiva@users.noreply.github.com> Date: Wed Apr 16 20:46:06 2025 +0700 refactor(context-menu): handle filename display better (#2684) * refactor(context-menu): handle filename display better * refactor(context-menu): reduce string computation commit c980662728904e734f657fd8c197ecb5d0018843 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Wed Apr 16 03:44:17 2025 -0700 Changeset version bump (#2683) * changeset version bump * Updating CHANGELOG.md format * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: R00-B0T Co-authored-by: Matt Rubens commit 2d360dc59e9752247af8196999ff313c78ba8eb8 Author: Matt Rubens Date: Wed Apr 16 06:40:43 2025 -0400 Fix select dropdown styling (#2682) commit 923e391bb84f54165bb1be18e09033b80873c64b Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Tue Apr 15 22:22:03 2025 -0700 Changeset version bump (#2676) * changeset version bump * Updating CHANGELOG.md format * Update CHANGELOG.md * Update package.json * Update package-lock.json * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: R00-B0T Co-authored-by: Matt Rubens commit e2d649dc5071617256ac982700255559e57b69a6 Author: Matt Rubens Date: Wed Apr 16 01:16:06 2025 -0400 v3.12.0 (#2674) commit 82df05a2975652d29a81857af8eca81027fa1927 Author: Matt Rubens Date: Tue Apr 15 23:09:18 2025 -0400 Fix configuration titles (#2672) commit 1cca4f47c7a9614ff7add9b6db4f83f3e04894f9 Author: Aleksandr Kirillov <32141102+axkirillov@users.noreply.github.com> Date: Wed Apr 16 05:01:45 2025 +0200 feat: Add 'roo.acceptInput' command (#2598) * feat: Add 'roo.acceptInput' command * Update package.nls.json * Update translations --------- Co-authored-by: Matt Rubens commit 37f7d8379218f584c31f2389571f06942aeb6a6d Author: Matt Rubens Date: Tue Apr 15 22:16:52 2025 -0400 Add xAI provider (#2667) * Add xAI provider * Add model reasoning effort * DRY this up * Handle undefined delta * Cleanup getModel to fix test * Add missing translations * Small type cleanup * Support temperature --------- Co-authored-by: cte commit 3b19d7a45510a65e55d340c0b66fe14ba24edda1 Author: Chris Estreich Date: Tue Apr 15 16:57:12 2025 -0700 Await checkpoint saves (except the initial) (#2665) commit 75a6bc100e0ff8b728298ebb3f9a26d86e0e98c1 Author: Matt Rubens Date: Tue Apr 15 19:49:25 2025 -0400 Safe JSON parse in ChatRow (#2666) commit 1e77f62063336daf6e6c175e199886056494c86c Author: Smartsheet-JB-Brown Date: Tue Apr 15 15:39:36 2025 -0700 remove improvement proposals because I already did them commit 7fab2f7738092290af8adc45117b62c5b1fec6bb Author: Dominik Oswald <6849456+d-oit@users.noreply.github.com> Date: Tue Apr 15 23:57:42 2025 +0200 feat: Cost Display in Task Header - Suppress Zero Cost Values and Ensure Visibility for Gemini, OpenAI, LM Studio, and Ollama (#2662) test: Add unit tests for TaskHeader component cost display logic commit 8839d722438398222cd3de00fa45004133fce7c3 Author: Smartsheet-JB-Brown Date: Tue Apr 15 14:40:00 2025 -0700 stragllers commit da2ab6e3882b547461a4a2c07034c6994afd500e Author: KJ7LNW <93454819+KJ7LNW@users.noreply.github.com> Date: Tue Apr 15 14:37:27 2025 -0700 test: limit Jest worker count to 40% per suite (#2658) - Each test suite (extension/webview) limited to 40% CPU usage - Total CPU utilization capped at 80% (40% x 2 suites) - Reserves 20% CPU for system and user tasks - Prevents memory thrashing and system slowdown - Reduces risk of OOM kills on memory-constrained systems - Maintains smooth UI responsiveness during test runs - Improves test reliability while keeping parallel execution Signed-off-by: Eric Wheeler Co-authored-by: Eric Wheeler commit 1bbfd2e8e676f3f914c3a92a638e92a6ef02228d Author: Chris Estreich Date: Tue Apr 15 14:36:13 2025 -0700 DRY up the auto-approve toggles (#2664) * DRY up the auto-approve toggles * Better subtask icon per @joemanley201 commit e7a57ea7747bd20562d18f4df6a0e31b926ee6d2 Author: Chris Estreich Date: Tue Apr 15 13:51:52 2025 -0700 Expose reasoning effort option for reasoning models on OpenRouter (#2483) * Specify reasoning effort for OpenRouter reasoning models * Add ReasoningEffort type * Fix ReasoningEffort props * Remove copypasta * Set reasoning effort for Grok 3 Mini * Use translations * Add translations * Remove this check commit 448fb3dd202d4aed3717a0d393146d81ea8158a7 Author: Smartsheet-JB-Brown Date: Tue Apr 15 13:50:53 2025 -0700 update implementation documentation commit b669edd82bec403a3abd0b146d3be077b53c4a4b Author: Smartsheet-JB-Brown Date: Tue Apr 15 13:38:38 2025 -0700 fix author and authorUrl display commit 51bcade4c5ea3f400d98652ec75df49294e226d1 Author: Matt Rubens Date: Tue Apr 15 16:20:20 2025 -0400 Better string normalization for diffs (#2659) commit f5b97baac46b36c9d08a2fa48d9bb685f19510a1 Author: Smartsheet-JB-Brown Date: Tue Apr 15 13:15:05 2025 -0700 translations commit 8eec97af2584a6f7242964e8e230ee30edced34b Author: Smartsheet-JB-Brown Date: Tue Apr 15 13:09:04 2025 -0700 component details matched tags showing correctly commit c0672358c7bff7ac5a605887796d647988d6075e Author: Smartsheet-JB-Brown Date: Tue Apr 15 12:47:00 2025 -0700 Remove problematic debounce logic commit 648c6e7d2817ed48956ff9ea1430b8f4ebdc0266 Author: Matt Rubens Date: Tue Apr 15 14:59:27 2025 -0400 Move diff editing config to provider settings (#2655) * Move diff editing config to provider settings * Fix tests commit a3b2ebc026ed09293702d16696e20443aaf5a740 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue Apr 15 14:41:12 2025 -0400 Update contributors list (#2595) docs: update contributors list [skip ci] Co-authored-by: cte commit a4d2de4534d32239a3f605cf5ac9078066b3ac97 Author: Chris Estreich Date: Tue Apr 15 11:29:38 2025 -0700 Add pass / fail events for evals (#2656) commit 74a6de7b6fccc21de5c10f8cfcb993792a515b2d Author: Smartsheet-JB-Brown Date: Tue Apr 15 10:14:47 2025 -0700 remove un-needed refresh button commit 4e645b457bd1dadd324d68f4313262190b6f281c Author: Smartsheet-JB-Brown Date: Tue Apr 15 10:07:50 2025 -0700 sorting controls work commit d966884b1d85551f113cdea4e0b18ddaf7a86cf3 Author: Smartsheet-JB-Brown Date: Tue Apr 15 10:01:57 2025 -0700 card item buttons working again commit 5f19ea4a06b7172b4089d307c1c878f34cdae2bd Author: Matt Rubens Date: Tue Apr 15 10:49:01 2025 -0400 Add telemetry for prompt enhancement (#2651) commit 31fd6e1470d20aa1c26296633670af899a144d8f Author: Matt Rubens Date: Tue Apr 15 10:37:48 2025 -0400 Capture telemetry for usage of code actions (#2650) commit f551fe2476cecf90cca53fe835fd75e9591a2947 Author: Smartsheet-JB-Brown Date: Tue Apr 15 07:26:27 2025 -0700 All display of items seems to work properly commit afbf174e19b3943853b52249287e6107821a77ca Author: Matt Rubens Date: Tue Apr 15 10:17:17 2025 -0400 Add telemetry for consecutive mistake error (#2649) commit 84c574c947c71bba058238ca1917247122b4776a Author: Sam Hoang Van Date: Tue Apr 15 21:14:39 2025 +0700 Fuzzy search bar select dropdown (#2635) * Revert "Revert "feat: implement fuzzy search and dropdown grouping in SelectDropdown component" (#2627)" This reverts commit f1ad8ab7510451345eb2c493f51ea6f301e6b1c4. * Fix double scroll bar on provider select dropdown * Update webview-ui/src/components/ui/select-dropdown.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: Matt Rubens Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> commit ab0e7cd81ee622e07ef1fb7b3130e2a82b7806b7 Author: Smartsheet-JB-Brown Date: Tue Apr 15 00:00:32 2025 -0700 source change results in reload commit 309c8894834ab18dc9a22d2d21c98fa0e2b7fe52 Author: Smartsheet-JB-Brown Date: Mon Apr 14 23:47:52 2025 -0700 deleting a source causes browse refresh commit 15d310469c65f4c01cc24342f6b508703e2ca0d6 Author: Smartsheet-JB-Brown Date: Mon Apr 14 23:23:39 2025 -0700 initialize first load with data on browse commit 9a5023504c1e4e60ae30aff3b0f27eb2cff18617 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Mon Apr 14 23:15:02 2025 -0700 Changeset version bump (#2629) * changeset version bump * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens commit 6e567845cbc9f2ff4e5db857f1b00484811984d9 Author: Chris Estreich Date: Mon Apr 14 23:11:13 2025 -0700 Remove auto-approve button useMemo (#2630) commit 249d53b30dcb80eb942e05d944585aeb6eda5827 Author: Matt Rubens Date: Tue Apr 15 02:06:58 2025 -0400 v3.11.17 (#2628) commit 75dce909f018fed5310acea50afcfd37c21635c1 Author: Smartsheet-JB-Brown Date: Mon Apr 14 23:00:21 2025 -0700 Add default source on first load commit f1ad8ab7510451345eb2c493f51ea6f301e6b1c4 Author: Chris Estreich Date: Mon Apr 14 22:53:52 2025 -0700 Revert "feat: implement fuzzy search and dropdown grouping in SelectDropdown component" (#2627) Revert "feat: implement fuzzy search and dropdown grouping in SelectDropdown …" This reverts commit 89107b82a345f42cd1ad10bf7243d0a949cd4785. commit ad91533205c221299b96b848f12eefc920600fcf Author: Smartsheet-JB-Brown Date: Mon Apr 14 22:47:56 2025 -0700 re add default source when all sources are deleted commit 1cdedf3048357162c008cbb9450aa1e6221afe86 Author: Chris Estreich Date: Mon Apr 14 22:47:27 2025 -0700 Update translations (#2623) * Update translations * Clean up the buttons commit c92be6aadb56ea4b7105131fe6269d069a90a1fc Author: Smartsheet-JB-Brown Date: Mon Apr 14 22:42:11 2025 -0700 more unit test work commit 00609aa2f3a447e7fbf6f1325bcfdd66609ba24e Author: KJ7LNW <93454819+KJ7LNW@users.noreply.github.com> Date: Mon Apr 14 22:34:45 2025 -0700 fix: race in short-running command output capture (#2624) Previously, handlers for terminal output were registered after starting the process, which could cause output to be missed for fast-executing commands that complete before handlers are set up. - Adds CommandCallbacks interface to register handlers upfront - Moves handler registration before process start to ensure no output is missed - Provides process instance in callbacks for direct control - Adds debug logging to help diagnose timing issues Signed-off-by: Eric Wheeler Co-authored-by: Eric Wheeler commit 4c88e930e9138ee1fb0831d837be422808dbcb3f Author: Smartsheet-JB-Brown Date: Mon Apr 14 22:24:41 2025 -0700 all package manager related tests passing together commit c05d760747a8f762394671d46ba34cbda3292fd9 Author: Smartsheet-JB-Brown Date: Mon Apr 14 21:59:39 2025 -0700 PackageManagerView tests all passing commit beb151e1b056544bb5fe05192c27b12283a4744d Author: Smartsheet-JB-Brown Date: Mon Apr 14 21:59:22 2025 -0700 PackageManagerView tests all passing commit 4f5796c152986dbb9f800d944836c6f65bfcb85c Author: Matt Rubens Date: Tue Apr 15 00:27:34 2025 -0400 Add telemetry for diff errors (#2619) commit 111ac9ca4765407cc8976e361ee6596f578a1326 Author: Sacha Sayan Date: Tue Apr 15 00:20:54 2025 -0400 UI Fix: Approve Tool Use Grid Toggles. (#2487) Improv auto approve layout + refactor. Button grid layout. Consolidates shortNames and labels, includes translations. commit 5b48a2d708c8a2b1e3f0f8dd2b6b7f7695857904 Author: Chris Estreich Date: Mon Apr 14 21:13:08 2025 -0700 More small evals tweaks (#2620) commit 89107b82a345f42cd1ad10bf7243d0a949cd4785 Author: Sam Hoang Van Date: Tue Apr 15 11:05:42 2025 +0700 feat: implement fuzzy search and dropdown grouping in SelectDropdown component (#2431) * feat: implement fuzzy search and dropdown grouping in SelectDropdown component * refactor: optimize SelectDropdown component with memoization and improved performance * Remove focus output and translate placeholder --------- Co-authored-by: Matt Rubens commit a64cab92dc9516f4a9fe044f4cdb5380d899f8b1 Author: Matt Rubens Date: Mon Apr 14 22:55:06 2025 -0400 Fix openai cache tracking and cost estimates (#2616) * fix(api): update cacheReadsPrice for OpenAI GPT-4.1 models (#2887) Set correct cacheReadsPrice (cached input price) for gpt-4.1, gpt-4.1 mini, and gpt-4.1 nano based on official OpenAI pricing. No changes to cacheWritesPrice as per current OpenAI documentation. This ensures prompt caching costs are accurately reflected for these models in cost calculations. * Update more OpenAI cache prices * Track cache tokens and cost correctly for OpenAI * Update tests --------- Co-authored-by: monotykamary commit 1eb29be33d5be1b6063c1a07bbabde88007b3c07 Author: Matt Rubens Date: Mon Apr 14 20:39:27 2025 -0400 Remove the end_line from the multi_diff instructions and logic (#2615) commit 72d9be2a6ccd904833c21921d7c5c99b7e3bfe63 Author: Smartsheet-JB-Brown Date: Mon Apr 14 17:13:26 2025 -0700 packagemanagermanager tests passing commit e1c590442cb66a14b952f81d14b5c3a30096ee12 Author: Smartsheet-JB-Brown Date: Mon Apr 14 17:10:09 2025 -0700 PackageManagerSourceValidation, GitFetcher, and MetadataScanner tests all pass commit 30c3a65b7bf55bd88c70667b9bb9baebe73af298 Author: nobuo kawasaki Date: Tue Apr 15 08:25:09 2025 +0900 Fix eslint error about --ext option by remove it (#2543) commit 1a123ed3073185f8d79849bb36c06c7b76fd8378 Author: Smartsheet-JB-Brown Date: Mon Apr 14 15:20:35 2025 -0700 documentation update commit 39332513aed3cfb32c49bdb367fa2bf44f397741 Author: Smartsheet-JB-Brown Date: Mon Apr 14 15:00:14 2025 -0700 documentation updates commit 6f8f8c6a1d9106cce1fd133ba86eae4dbebab105 Author: Chris Estreich Date: Mon Apr 14 14:51:53 2025 -0700 Parse providers individually (#2611) * Parse providers individually * Remove .strict() * Clean up tests commit b196489b97dfdcb1bbdb4d6f702b7a1654761cbd Author: Matt Rubens Date: Mon Apr 14 17:09:38 2025 -0400 Fix overdependence on start/end lines in diff strategy (#2567) * Add test for issue #2556 * Fix overdependence on start/end lines in diff strategy commit af6c27bdff28781717ea941ad5b36585466b1d83 Author: Smartsheet-JB-Brown Date: Mon Apr 14 13:33:58 2025 -0700 locale fixes for metadata commit 71d30b835bf2602ea725037896c96e236677bd5f Author: Smartsheet-JB-Brown Date: Mon Apr 14 12:37:27 2025 -0700 documentation start commit 45fb958505c90025680a8213b989710b4b218cdf Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Mon Apr 14 10:32:18 2025 -0700 Changeset version bump (#2606) * changeset version bump * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens commit ab7ca17f2905c605e1e0d56cbdf959aed5fbdb53 Author: Matt Rubens Date: Mon Apr 14 13:27:33 2025 -0400 Fix test (#2607) commit 929503a4c8c2f1650d253e5a01094152f5e8c10c Author: Matt Rubens Date: Mon Apr 14 13:21:47 2025 -0400 Add GPT 4.1 (#2605) commit 2ca7a85c207ad6af9047eec78dbb01145add9784 Author: Smartsheet-JB-Brown Date: Mon Apr 14 10:13:48 2025 -0700 consolidate packagemanager test files commit dee7510ed71396f1a815466822885f9615c9264a Author: Smartsheet-JB-Brown Date: Mon Apr 14 09:18:48 2025 -0700 searching finds items in components of a pcakage and shows which ones match commit 8cf3c532cf725099fb8ddd68e8a75733c0a6961d Author: Hannes Rudolph Date: Mon Apr 14 09:58:52 2025 -0600 Update log messages for Cline instances to Roo Code instances in regi… (#2604) Update log messages for Cline instances to Roo Code instances in registerCommands.ts and corresponding test adjustments commit d73789cfe4fb6f5dfb921d86965c26c33c0b31a2 Author: Chris Estreich Date: Mon Apr 14 08:15:20 2025 -0700 Update default settings for evals (#2601) commit 81304a22ea6453e7497c16d61820a57f48299951 Author: Smartsheet-JB-Brown Date: Mon Apr 14 06:54:17 2025 -0700 package details visible in expandable section commit 4fe0b3c4191304ba27e5b9328be1bbb5ca1883aa Author: feifei <46489071+feifei325@users.noreply.github.com> Date: Mon Apr 14 19:20:40 2025 +0800 feat: Add modelId support when exporting tasks (#2142) Added modelId to the task export process. Signed-off-by: feifei commit 965a7ae90c747b3eb7d6a2fd789e2d2d511d89f6 Author: Smartsheet-JB-Brown Date: Sun Apr 13 21:01:47 2025 -0700 refactor(search): simplify to basic string contains match while preserving subcomponent handling - Replace complex word boundary/regex matching with simple string.includes() - Maintain all subcomponents in search results - Preserve proper matchInfo for packages and subcomponents commit df5be205f5b623ed7e30c51ab937aa41dd192202 Author: Smartsheet-JB-Brown Date: Sun Apr 13 20:35:42 2025 -0700 test: add comprehensive substring matching tests for package manager search - Add test cases for case-insensitive matching - Add test cases for partial string matching - Add test cases using real data from package-manager-template - Verify search behavior with actual component data commit 3ffe61f548e7093b217118459fbe795211de5572 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Sun Apr 13 20:33:12 2025 -0700 Changeset version bump (#2587) * changeset version bump * Updating CHANGELOG.md format * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: R00-B0T Co-authored-by: Matt Rubens commit 180f9045282e11a2f97e73999259ec9b6e6c5bef Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun Apr 13 23:10:53 2025 -0400 Update contributors list (#2583) docs: update contributors list [skip ci] Co-authored-by: mrubens commit 73a6ab8acd31f000218fc1b1ca687896c6f32f0e Author: Matt Rubens Date: Sun Apr 13 22:57:36 2025 -0400 v3.11.15 (#2586) commit db85df86d9ba4daeecaaab2362445792e3d3cc58 Author: Matt Rubens Date: Sun Apr 13 22:53:15 2025 -0400 Update NLS translations (#2584) commit eab61289a280929745f294c5971efcd9c960e530 Author: Matt Rubens Date: Sun Apr 13 22:27:28 2025 -0400 Add workspace filter to historypreview as well (#2582) * Add workspace filter to historypreview as well * PR feedback commit dff42d28c0444d3c223fc09d3ba8a964eec61e38 Author: Smartsheet-JB-Brown Date: Sun Apr 13 12:24:48 2025 -0700 feat(package-manager): enhance subcomponent metadata scanning - Add recursive scanning for nested components - Fix path handling for nested directories - Improve test coverage for package subcomponents - Fix timestamp handling in git-based dates commit 1c03234d645187182ff2fb234c613b7d90762330 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun Apr 13 13:52:38 2025 -0400 Update contributors list (#2519) docs: update contributors list [skip ci] Co-authored-by: mrubens commit ff92a6128198b9c2db7297de71f5fc8048cca65b Author: Matt Rubens Date: Sun Apr 13 13:51:14 2025 -0400 Revert "Add o1-pro to api.ts" (#2574) Revert "Add o1-pro to api.ts (#2433)" This reverts commit 16d8f143718d498a2b868014b8574963f38dc87e. commit 494af3090abf9c7929164ac44deb72b6b54f7cb5 Author: Sam Hoang Van Date: Mon Apr 14 00:44:03 2025 +0700 fix: normalize file paths to POSIX format in search results (#2569) commit caf1ae35e194a6a0a165ba557dbd996f03fdb34d Author: pokutuna Date: Mon Apr 14 02:42:59 2025 +0900 fix: Restore focus ring for VSCodeButton component (#2572) commit 4ecec98384be247744ea4cb8218c5285d7c787c6 Author: Chris Estreich Date: Sun Apr 13 10:30:06 2025 -0700 Support all providers in evals settings (#2573) commit 352a6b9c75e6a07be665e5255a1c52c6529e31db Author: Chris Estreich Date: Sun Apr 13 09:44:45 2025 -0700 More unit test kill -9 fixes (#2570) * More unit test kill -9 fixes * Fix linter warning commit 8bb3839b4d5b52803c97941e316a51d402bc6556 Author: Zhang Tony <157202938+zhangtony239@users.noreply.github.com> Date: Sun Apr 13 22:09:46 2025 +0800 fix: background color for new profile dialog (#2560) commit adadc3add25285d227e38a0b73f1a00cbd389ebd Author: Sam Hoang Van Date: Sun Apr 13 15:00:23 2025 +0700 fix build vsix package (#2554) commit ef9b3390278cf2087d900bc1f4c6c0c271e04bbf Author: Chris Estreich Date: Sun Apr 13 00:57:33 2025 -0700 Evals improvements (#2555) * Evals improvements * Remove debugging commit 4891fb04b79d28a17b9b6e35ca2514fb6a23f933 Author: Smartsheet-JB-Brown Date: Sat Apr 12 23:12:40 2025 -0700 feat: implement git-based lastUpdated dates commit 3e8d35b490ced65db2a807af3b05f2fc83a57fc7 Author: Smartsheet-JB-Brown Date: Sat Apr 12 23:11:03 2025 -0700 fix: package manager refresh state handling and item display commit d3c65ceac6cf66c0284bf8ee014b4c1c158c1bc2 Author: Zhang Tony <157202938+zhangtony239@users.noreply.github.com> Date: Sun Apr 13 13:16:01 2025 +0800 feature: Closable welcome message (#2541) * draft: try to add a setting button * Add showGreeting setting and related changes * i18n: showGreeting * fix chinese i18n 'dot' commit 6f1befb2355666c494de7ff7c5f80fab9875f394 Author: Smartsheet-JB-Brown Date: Sat Apr 12 21:56:52 2025 -0700 feat: improve package scanning - Stop scanning at package boundaries - Remove items array handling (only for external refs) - Add comprehensive test coverage commit 172f8d747cee4def51cb35cae47eae5779fe6ee5 Author: Smartsheet-JB-Brown Date: Sat Apr 12 21:36:25 2025 -0700 feat: enhance iterate routine with progressive git operations and post-validation workflow commit 9666a56a0e44dbcce2f97c3ff44c152cc35761a0 Author: Smartsheet-JB-Brown Date: Sat Apr 12 21:28:59 2025 -0700 feat: enhance iterate routine with progressive git operations commit 14de4899b8bbbbe04acdd4a70e45b97fd6c29881 Author: Sam Hoang Van Date: Sun Apr 13 11:15:08 2025 +0700 Add localization support for Roo Code extension in multiple languages (#2523) * Add localization support for Roo Code extension in multiple languages - Created new localization files for Catalan, German, Spanish, French, Hindi, Italian, Japanese, Korean, Polish, Portuguese (Brazil), Turkish, Vietnamese, Chinese (Simplified), and Chinese (Traditional). - Updated extension activation and deactivation messages to use localized strings. - Enhanced user experience by providing translated command titles and descriptions for various functionalities within the extension. * Revert changes to extension.ts * Remove l10n --------- Co-authored-by: Matt Rubens commit e94e58ff1ab3cb118e2d3e787353b1ad486cc349 Author: KJ7LNW <93454819+KJ7LNW@users.noreply.github.com> Date: Sat Apr 12 20:54:43 2025 -0700 docs: document process for adding new settings (#2552) Add documentation explaining how to implement new settings in Roo Code, using the command risk level feature as a practical example. Signed-off-by: Eric Wheeler Co-authored-by: Eric Wheeler commit 08110ae35bbc0dbffd1f91f6b82e75938d57421d Author: Sam Hoang Van Date: Sun Apr 13 10:20:14 2025 +0700 Filter & Search Workspace Task History (#2526) * Enhancement: Add 'Show all workspaces' feature to task history and update translations * Enhancement: Add Checkbox component and integrate it into HistoryPreview and HistoryView * Simplify the UX --------- Co-authored-by: Matt Rubens commit 628d232f9fc6972c81cee05a2115fc79527c8ca4 Author: vagadiya <32499123+vagadiya@users.noreply.github.com> Date: Sun Apr 13 03:23:48 2025 +0100 Fix AWS token expiry issue when cached token expires when using AWS Profile for Bedrock (#2469) (#2530) Fix AWS token expiry issue when cached token expires and using AWS Profile (#2469) commit 37cfd75ebfe7e26ea4f1999265d0849b3ddc6bb8 Author: mecab Date: Sun Apr 13 03:20:03 2025 +0100 Add Anthropic option to pass API Token as Authorization header instead of X-Api-Key for the custom base URL (#2531) Add Anthropic option to use authToken over apiKey commit 7a62bc504c0aa566fe8eb71d1e74bc5032595812 Author: Smartsheet-JB-Brown Date: Sat Apr 12 17:44:31 2025 -0700 refactor: remove package manager e2e tests in favor of enhanced unit tests - Removed e2e/src/suite/package-manager.test.ts - Reverted src/__mocks__/vscode.js to simpler version - Added enhanced unit tests with better coverage: * Cache directory handling * Localization testing * External items validation * Security checks * Error handling Task ID: e2e_analysis_20250412 commit 61c9480b5b9542350132bbaf624ce793c8f194d4 Author: Smartsheet-JB-Brown Date: Sat Apr 12 17:34:00 2025 -0700 refactor: rename prepare-for-commit to iterate - Renamed prepare_logs to iterations - Updated task manager to use new terminology - Simplified CLI interface - Added better TypeScript types - Improved error handling Task ID: 4d3f7a2e-8c1b-4f9d-b5e2-9c1d8f3a6b4d commit 589b1596870ddf99f4d6d66aa85f83c8dccf9797 Author: Smartsheet-JB-Brown Date: Sat Apr 12 17:30:11 2025 -0700 refactor: remove unused YamlParser implementation - Removed src/services/package-manager/YamlParser.ts - Verified no test files or imports existed - All tests passing (1263 pass, 0 fail, 23 pending) Task ID: 4d3f7a2e-8c1b-4f9d-b5e2-9c1d8f3a6b4d commit 4417886324a54ad5c058813474b8a57a9859bba0 Author: Smartsheet-JB-Brown Date: Sat Apr 12 16:43:32 2025 -0700 checkpoint: pre-package-manager-state-fix commit 294b52ef6a4ae4edc48d65349346b32897c0fec4 Author: vagadiya <32499123+vagadiya@users.noreply.github.com> Date: Sat Apr 12 18:43:35 2025 +0100 Fix to Bedrock ARN validation (#2538) Fixes Bedrock ARN validation Updates the Bedrock ARN regex to allow alphanumeric characters, dots, hyphens, and colons in the resource ID. This prevents validation errors when using ARNs containing those characters. commit 973be7640e302a1add8274dfa5325bdc52ef4ba1 Author: Smartsheet-JB-Brown Date: Sat Apr 12 10:22:33 2025 -0700 working mvp commit 6b9b2aa4defcf787c8f468805af98f13132038ee Author: Smartsheet-JB-Brown Date: Sat Apr 12 09:55:08 2025 -0700 no type script or linting errors commit e9980bcfa944c312ad827edc11ffa4cb38a43b88 Author: Sam Hoang Van Date: Sat Apr 12 19:28:41 2025 +0700 Fix duplicate mention suggestion (#2528) improve deduplication logic in getContextMenuOptions to handle context menu item keys more accurately commit 2c8304ea31bdf1d67d6ef3da4eafa440dbd927d7 Author: Chris Estreich Date: Sat Apr 12 01:06:03 2025 -0700 Fix node version string when running asdf install nodejs (#2524) commit 91178628aa0868782f54e89a4211f87376444eff Author: Chris Estreich Date: Sat Apr 12 00:07:30 2025 -0700 Evals enhancements: delete runs, show all run instead of just completed runs (#2520) commit c25163aa208d9f7351dceab787685e34c253b5a2 Author: Bogdan Dolin Date: Sat Apr 12 13:09:18 2025 +0700 Fix: Remove 'v' prefix from Node.js version in .tool-versions file (#2515) Fix formatting of Node.js version in .tool-versions commit df01e7948b1ffc88a0d7cda16cb4714dd732df72 Author: Chris Estreich Date: Fri Apr 11 22:45:14 2025 -0700 Add reasoningEffort to provider settings schema (#2518) commit 6ab9aa9f15fb5a0bf960c7bde88199cec84852ea Author: Chris Estreich Date: Fri Apr 11 22:21:15 2025 -0700 Control evals concurrency in web app (#2265) commit 1f6da88d24b7a81317487118038fd00d3afc5e72 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat Apr 12 00:34:38 2025 -0400 Update contributors list (#2516) docs: update contributors list [skip ci] Co-authored-by: mrubens commit e10c25e0a4659f1006d676e6898ede21eac7a157 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Fri Apr 11 21:25:55 2025 -0700 Changeset version bump (#2517) * changeset version bump * Updating CHANGELOG.md format * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: R00-B0T Co-authored-by: Matt Rubens commit 624691abb05d322d1322301b10ad1a785f210e01 Author: Matt Rubens Date: Sat Apr 12 00:14:39 2025 -0400 Respect the setting to always read the full file (#2514) commit e453690e7fb3ed35cee049c79eadb8d31f30a044 Author: Taisuke Oe Date: Sat Apr 12 12:32:57 2025 +0900 Fix bug not to respect symbolic linked rules, if target is a directory or another symbolic link (#2513) * read symbolic linked dir and files recursively * add symlinked dir and nested symlink test case for custom-instructions * enhance comments * add changeset commit 2eba534dd6b1f94c5f9d6e7cb4756f4fb17e08fd Author: Chris Estreich Date: Fri Apr 11 14:54:03 2025 -0700 Evals fixes (#2505) * Allow Turso URLs, add support for API providers beyond OpenRouter * Make the git branch name unique commit 15b91ab03c9c115207321287ee7edadcdb835852 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Fri Apr 11 14:47:29 2025 -0700 Changeset version bump (#2504) * changeset version bump * Updating CHANGELOG.md format * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: R00-B0T Co-authored-by: Matt Rubens commit 4048d36ab4e10671b31680e3fdbf5faf3d93d0bf Author: Matt Rubens Date: Fri Apr 11 17:36:00 2025 -0400 v3.11.13 (#2503) commit aaf0567e8df9c8e43efef9d46abdd900290c7350 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri Apr 11 17:35:40 2025 -0400 Update contributors list (#2501) docs: update contributors list [skip ci] Co-authored-by: mrubens commit ba307f8e1fa6f1688298209cba6552baba1d5129 Author: Matt Rubens Date: Fri Apr 11 17:32:29 2025 -0400 Revert "♻️ refactor(webview): move webview HTML generation to WebviewHTMLManager" (#2502) Revert "♻️ refactor(webview): move webview HTML generation to WebviewHTMLMana…" This reverts commit e70954f3de2fc414d678a1c0c0da71a2a023d41b. commit 9c3c93567ba5352a5099bd56761c5914ec57cb01 Author: KJ7LNW <93454819+KJ7LNW@users.noreply.github.com> Date: Fri Apr 11 14:10:54 2025 -0700 Merge pull request #2427 from KJ7LNW/fix-vscode-lm-content-preservation fix: preserve content integrity in VS Code LM provider commit 9dead72aceb2fd630f49527258ce2318b1ec262f Merge: e70954f3 ae0ab566 Author: Matt Rubens Date: Fri Apr 11 17:04:42 2025 -0400 Merge pull request #2456 from KJ7LNW/fix-misc-terminal-issues Terminal improvements: command delay, PowerShell counter, and ZSH EOL mark commit ae0ab56654bec0a32601834754ff4348bb2bdda1 Author: Eric Wheeler Date: Fri Apr 11 13:23:57 2025 -0700 intl: enhance shell integration troubleshooting translations Add new i18n strings for shell integration steps and expand troubleshooting text across all supported languages Signed-off-by: Eric Wheeler commit 4ef62c6a1358cfca9756a084d4c17404a3c604ec Author: Eric Wheeler Date: Thu Apr 10 21:18:02 2025 -0700 feat: add ZDOTDIR handling for zsh shell integration Creates a temporary ZDOTDIR to handle zsh shell integration properly while preserving user's zsh configuration. This ensures VSCode shell integration works correctly with zsh without modifying the user's existing setup. - Add terminalZdotdir setting (disabled by default) - Create temporary directory with proper security (sticky bit) - Add automatic cleanup on terminal close - Add translations for all supported languages User confirmed fixes: Fixes: #2205 Fixes: #2129 Signed-off-by: Eric Wheeler commit e70954f3de2fc414d678a1c0c0da71a2a023d41b Author: Bhavesh Ramburn Date: Fri Apr 11 20:40:07 2025 +0100 ♻️ refactor(webview): move webview HTML generation to WebviewHTMLManager (#2494) - 【What】Move the logic for generating webview HTML content from ClineProvider to a new WebviewHTMLManager class. - 【Why】This improves code organization and maintainability by separating concerns related to webview HTML generation. - 【Why】This allows for easier testing and modification of the HTML generation logic without affecting the ClineProvider class. commit 405e599206f4668e352d63a01be834218ff69cd5 Author: Matt Rubens Date: Fri Apr 11 15:38:59 2025 -0400 Exclude demo gif from extension build (#2481) commit aa066935ceca63512623ca76ddf53db16b33e991 Author: Smartsheet-JB-Brown Date: Fri Apr 11 06:34:50 2025 -0700 fix linting errors commit d323015b25e1872464a14036760a7a5d4837db5e Author: Smartsheet-JB-Brown Date: Fri Apr 11 06:22:50 2025 -0700 UI Control work commit b4c67f133b606ba45d6a78793cd1795c6ca4d908 Author: Eric Wheeler Date: Thu Apr 10 17:38:56 2025 -0700 feat: add terminal settings for Oh My Zsh and Powerlevel10k shell integration Added two new terminal settings: - terminalZshOhMy: Sets ITERM_SHELL_INTEGRATION_INSTALLED=Yes for Oh My Zsh - terminalZshP10k: Sets POWERLEVEL9K_TERM_SHELL_INTEGRATION=true for Powerlevel10k Signed-off-by: Eric Wheeler commit b020e4607674b9315cd03722e6c1e0cb8b6e95b9 Author: Eric Wheeler Date: Wed Apr 9 22:12:14 2025 -0700 fix: clear ZSH EOL mark to prevent command output interpretation issues Added a new configuration option 'terminalZshClearEolMark' (default: true) that sets PROMPT_EOL_MARK='' in the terminal environment. This prevents issues with command output interpretation when the output ends with special characters like '%'. Added translations for all supported languages. Fixes: #2194 Signed-off-by: Eric Wheeler commit 211e31b8f6db42396afb96300c898511437a785f Author: Eric Wheeler Date: Wed Apr 9 21:50:33 2025 -0700 feat: add terminalPowershellCounter configuration option Add a new configuration option that allows users to toggle the PowerShell counter workaround. This workaround adds a counter to PowerShell commands to ensure proper command execution and output capture. The setting is disabled by default, allowing users to enable it only when needed. Signed-off-by: Eric Wheeler commit 4d1cfe81418819db0ec0472e926e80bbf080115f Author: Eric Wheeler Date: Wed Apr 9 20:48:49 2025 -0700 feat: add terminal.commandDelay setting Add a new configurable setting to control command execution delays in terminals. When set to a non-zero value, this adds a sleep delay after command execution via PROMPT_COMMAND in bash/zsh and start-sleep in PowerShell. The default value is 0, which disables the delay completely. This setting replaces the previous hardcoded delay of 50ms that was added as a workaround for VSCode bug #237208. Fixes: #2017 Signed-off-by: Eric Wheeler commit 902d6d5017ecc8aa18c8b264856bc1781779c8d9 Author: Eric Wheeler Date: Wed Apr 9 20:05:23 2025 -0700 fix: prevent UI hang when shell integration is unavailable When shell integration is unavailable, the UI would hang because the process was never properly released. This change fixes the issue by: - Emitting a 'completed' event with a descriptive message - Marking the terminal as not busy - Clearing the active stream - Allowing the process to continue Signed-off-by: Eric Wheeler commit 0b0c43828433db68a6989b38beb89bac1c8be71a Author: Eric Wheeler Date: Wed Apr 9 17:25:12 2025 -0700 fix: standardize terminal integration timeout values Replace hardcoded 3000ms timeout with configurable Terminal.shellIntegrationTimeout in TerminalProcess.ts. This ensures consistent timeout behavior across all terminal integration features and allows users to control both timeouts through a single setting. The error messages are also updated to display the dynamic timeout value, providing clearer feedback when shell integration issues occur. Signed-off-by: Eric Wheeler commit b8bf634fd2984fe4a778902c11ae0ae81122e1bc Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu Apr 10 23:53:03 2025 -0400 Update contributors list (#2466) docs: update contributors list [skip ci] Co-authored-by: mrubens commit 2ba7200f37c19fd559cce7b3688d63d40959f6a7 Author: Matt Rubens Date: Thu Apr 10 23:44:38 2025 -0400 Fix discard changes in settings (#2485) commit 3cc81c73cf741e7d517a4d6e3db0e57a38205b65 Author: KJ7LNW <93454819+KJ7LNW@users.noreply.github.com> Date: Thu Apr 10 19:34:18 2025 -0700 docs: update settings.md with comprehensive steps (#2451) * docs: update settings.md with comprehensive steps Update the settings documentation to include all necessary steps for adding a new configuration item, including schema definitions, type definitions, and critical steps for persistence and UI display. This ensures the documentation accurately reflects the complete process required when adding new settings to the application. Signed-off-by: Eric Wheeler * docs: add style considerations for checkbox settings Add documentation about styling checkbox settings in the UI, including: - Using VSCodeCheckbox component - Proper wrapping and spacing - Consistent styling for labels and descriptions - Example implementation based on terminalPowershellCounter Signed-off-by: Eric Wheeler * docs: add comprehensive guide for adding new configuration items Add a new section to settings.md that provides a complete checklist for adding new configuration items to the system. This guide covers all aspects from UI to persistence to functionality, based on implementation experience. Signed-off-by: Eric Wheeler * docs: update settings documentation with persistence guidelines Add comprehensive guidance for ensuring settings persist across reload Include debugging steps for troubleshooting persistence issues Replace 'Avoiding Duplicates' section with more detailed information Signed-off-by: Eric Wheeler --------- Signed-off-by: Eric Wheeler Co-authored-by: Eric Wheeler commit e41d6a42acc5fc318e6f493cd533ee49151d85a4 Author: Matt Rubens Date: Thu Apr 10 22:32:11 2025 -0400 Better display of diff errors (#2478) commit c0ba1f5080ef5b045428ea98406df37ed1dff038 Author: Hannes Rudolph Date: Thu Apr 10 19:55:52 2025 -0600 Update README.md to reflect new branding and add demo GIF; (#2479) * Update README.md to reflect new branding and add demo GIF; optimize demo GIF size * Update README.md to reflect branding change from "Roo Cline" to "Roo Code" and remove duplicate title entry. commit fd48ffcfe9bb8b1671524bf280cd4831ed8342c6 Author: Smartsheet-JB-Brown Date: Thu Apr 10 14:42:11 2025 -0700 add sourceURL option to yaml commit 77daf8559bd3a493d77feb268001aa201b0038bd Author: Steven T. Cramer Date: Fri Apr 11 03:51:27 2025 +0700 Move "Previously Roo Cline" to description from title (#2476) * Move "Previously Roo Cline" to description from title * Add a period. commit a031b74bf5f583ee9d9f448cde92677ee93c2751 Author: ronyblum Date: Thu Apr 10 12:47:34 2025 -0700 Modification of AWS Bedrock to Amazon Bedrock (#2473) * Modification of AWS Bedrock to Amazon Bedrock * Duplicated comment removal commit 655899673b807e9c7c437a2682a676981e58c040 Author: Smartsheet-JB-Brown Date: Thu Apr 10 12:26:12 2025 -0700 walking skeleton commit cd5b894578da5bd7ecbd667959335bdf2750076c Author: Smartsheet-JB-Brown Date: Thu Apr 10 12:25:12 2025 -0700 walking skeleton commit 255a158e758ede9a174b491b3bfcf93f7dd60de0 Author: Zhang Tony <157202938+zhangtony239@users.noreply.github.com> Date: Thu Apr 10 23:12:49 2025 +0800 Bug Fixed: Chinese i18n css error (#2470) * bug fixed: chinese i18n css error * Update webview-ui/src/components/settings/ApiOptions.tsx --------- Co-authored-by: Matt Rubens commit 5352beb95cbdb262ec01b3c03f3b5724e33505fa Author: Sam Hoang Van Date: Thu Apr 10 21:56:52 2025 +0700 feat: Add file context tracking system (#2440) * feat: Add file context tracking system This commit adds a comprehensive file context tracking system that monitors file operations (reads, edits) by both Roo and users. The system helps prevent stale context issues and improves checkpoint management. Key features: - Track files accessed via tools, mentions, or edits - Monitor file changes outside of Roo using file watchers - Store file operation metadata with timestamps - Trigger checkpoints automatically when files are modified - Prevent false positives by distinguishing between Roo and user edits The implementation includes: - New FileContextTracker class to manage file operations - Type definitions for file metadata tracking - Integration with all file-related tools - File mention tracking in the mentions system - Improved checkpoint triggering based on file modifications * Update src/core/context-tracking/FileContextTracker.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Update src/core/context-tracking/FileContextTracker.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * test: Add mocks for getFileContextTracker in Cline tests --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens commit 0ddfa4d0bf68b9909bbe53a9bc7158bfd6251a43 Author: Wojciech Kordalski Date: Thu Apr 10 15:51:27 2025 +0200 `.direnv` directory should not be packaged (#2464) If somebody uses direnv tool, the `.direnv` directory is created, that contains some data and especially some symlinks. Symlinks makes `vsce` to fail zipping the built VSIX. Generally `.direnv` should not be added to the resulting VSIX, therefore I add it to .vscodeignore. commit 82bf3dc42e805c2ca03e77c8379e957ac017784c Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Wed Apr 9 22:11:59 2025 -0700 Changeset version bump (#2455) * changeset version bump * Updating CHANGELOG.md format * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: R00-B0T Co-authored-by: Matt Rubens commit fba24ac13df5a33c346318d66e18556b23b4ee38 Author: Matt Rubens Date: Thu Apr 10 01:05:38 2025 -0400 v3.11.12 (#2454) commit 8da3538744286abc2e2d1e95f420daadd56dc493 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu Apr 10 01:05:18 2025 -0400 Update contributors list (#2434) docs: update contributors list [skip ci] Co-authored-by: mrubens commit 1445bb0a3cf0156093f77befa20e5d03b0827f1d Author: amittell Date: Thu Apr 10 00:35:49 2025 -0400 Make Grok3 streaming work with OpenAI Compatible (#2449) commit c18e25f4bc2c5ea491332e704eb3757c31d8bff2 Author: Matt Rubens Date: Thu Apr 10 00:29:50 2025 -0400 Fall back on aggressive line number stripping in diffs (#2453) * Add option for aggressive line number stripping * Fall back on aggressive line number stripping in diffs commit 4e716263d0d5cb612d4a0734100e3ee1bfabc404 Author: Chris Estreich Date: Wed Apr 9 21:18:07 2025 -0700 Add a script to copy eval run results to Turso (#2452) commit 5fa555e60d67df14d5a766aede1aebe144b95468 Author: Chris Estreich Date: Wed Apr 9 13:12:55 2025 -0700 Fix gh fork command (#2442) commit 6d9ebe3fcf48d97e31f911338f325ed3d7834c74 Author: Chris Estreich Date: Wed Apr 9 13:04:36 2025 -0700 More sane evals default concurrency + staggered startup (#2441) commit 5c3237a6b0e4c00556673d012b2f50f0caef8881 Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Wed Apr 9 08:41:00 2025 -0700 Changeset version bump (#2436) * changeset version bump * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens commit fb0dd75fe0412e529c83f5f7679a9047e86f529e Author: Chris Estreich Date: Wed Apr 9 08:20:17 2025 -0700 API fixes (#2438) commit eda53815ab802a8ee4a57bbbebed7616a21d0cb4 Author: Matt Rubens Date: Wed Apr 9 09:31:58 2025 -0400 v3.11.11 (#2435) commit 7e4000b4e0e5b6006bbef4e20689f8122f6f1348 Author: Matt Rubens Date: Wed Apr 9 09:20:13 2025 -0400 Add custom instructions for de (#2383) commit f6467ca371f8a2eebae2c06f913c1d88a31087bf Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed Apr 9 09:06:42 2025 -0400 Update contributors list (#2411) docs: update contributors list [skip ci] Co-authored-by: mrubens commit 16d8f143718d498a2b868014b8574963f38dc87e Author: arthur <51604173+arthurauffray@users.noreply.github.com> Date: Thu Apr 10 01:04:55 2025 +1200 Add o1-pro to api.ts (#2433) Add the o1-pro model to the openai section. Sourced model info from: https://platform.openai.com/docs/models/o1-pro commit 75ba1db3ca02807999d5c356d19b6236a7e8bb5e Author: Matt Rubens Date: Wed Apr 9 01:45:45 2025 -0400 Improve subtasks UI (#2426) commit 270fd88cc8d8814a0f3a31e381ee4d925a2518cd Author: KJ7LNW <93454819+KJ7LNW@users.noreply.github.com> Date: Tue Apr 8 20:04:44 2025 -0700 refactor: improve readFileTool XML output format (#2340) * fix: addLineNumbers handling of empty content Empty files should not have line numbers, but non-empty files with empty content at a specific line offset should. - If content is empty, return empty string for empty files - If content is empty but startLine > 1, return line number for empty content at that offset This ensures that the model does not think the file contains a single empty line. Signed-off-by: Eric Wheeler * refactor: improve readFileTool XML output format - Remove unnecessary XML indentation that could confuse the model - Separate file content from notices and errors using dedicated tags - Add line range information to content tags - Handle empty files properly with self-closing tags - Add comprehensive test coverage Fixes #2278 Signed-off-by: Eric Wheeler * fix: always show line numbers in read_file XML output - Always display line numbers in non-range reads - Improve XML formatting with consistent newlines for better readability Signed-off-by: Eric Wheeler * test: update tests to match new XML format with line numbers - Update test expectations to match the new XML format with newlines - Update tests to expect line numbers attribute in content tags - Modify test assertions to check for the correct line range values Signed-off-by: Eric Wheeler * fix: consistent blank line handling in addLineNumbers - Add newline to all output - Handle trailing newlines and empty lines consistently - Add test cases for blank lines: - Multiple blank lines within content - Multiple trailing blank lines - Only blank lines with offset - Trailing newlines Signed-off-by: Eric Wheeler * test: use actual addLineNumbers in read-file-xml tests - Modified extract-text mock to preserve actual addLineNumbers implementation - Removed mock implementation of addLineNumbers - Updated test data to account for trailing newline - Removed unnecessary mock verification Signed-off-by: Eric Wheeler * test: ensure actual addLineNumbers function is called in tests - Replace direct mocking of addLineNumbers with spy on actual implementation - Add verification to ensure the real function is called when appropriate - Add skipAddLineNumbersCheck option for cases where function should not be called - Update test cases to use appropriate verification options - Fix numberedFileContent to include trailing newline for consistency Signed-off-by: Eric Wheeler * fix: modify readLines to process data directly instead of line by line - Direct data processing provides more accurate results by preserving exact content with carriage returns - Improved performance through minimal buffering and efficient string operations - Use string indexes to find newlines while maintaining their original format - Handle all edge cases correctly with preserved line endings - Add tests for various edge cases including empty files, single lines, and different line endings Signed-off-by: Eric Wheeler * test: remove unused mockInputContent variable Remove unused variable declaration to appease ellipsis-dev linter requirements. Signed-off-by: Eric Wheeler --------- Signed-off-by: Eric Wheeler Co-authored-by: Eric Wheeler commit 2779e8f703705469171a1ed7da2d6b9013c22e0b Author: KJ7LNW <93454819+KJ7LNW@users.noreply.github.com> Date: Tue Apr 8 19:58:33 2025 -0700 fix: clarify difference between workspace directory and terminal working directory (#2418) * fix: clarify difference between workspace directory and terminal working directory This commit addresses confusion between the VS Code workspace directory and terminal working directory. Roo was not properly distinguishing between these concepts, leading to issues when terminal commands changed directories. - Renamed 'Current Working Directory' to 'Current Workspace Directory' throughout - Added clearer notice when a command changes the working directory in a terminal - Added explanation about the difference between workspace and working directories - Updated all tool descriptions to reference 'workspace directory' References: https://www.reddit.com/r/RooCode/s/6L19EvsFbF Signed-off-by: Eric Wheeler * test: update directory terminology in test files Update terminology from 'working directory' to 'workspace directory' in tests to reflect VSCode's concept of workspace vs working directory. Signed-off-by: Eric Wheeler --------- Signed-off-by: Eric Wheeler Co-authored-by: Eric Wheeler commit 63c8f923935c491e92f96f9dbb3508fe35124f22 Merge: c2fef2d0 c2dda05b Author: Matt Rubens Date: Tue Apr 8 22:57:07 2025 -0400 Merge pull request #2420 from KJ7LNW/tree-sitter-eval-definitions feat: enhance tree-sitter parsers for multiple languages commit c2fef2d01f136f6d9e6a760c24a9b37b0ad6aa74 Author: Matt Rubens Date: Tue Apr 8 22:51:58 2025 -0400 Follow symlinks for rules files (#2421) commit c2dda05b2c03d3f081d7ce3ba54a441826b0a0a6 Author: Eric Wheeler Date: Tue Apr 8 19:40:19 2025 -0700 feat: enhance Python tree-sitter parser with advanced language structures This commit significantly enhances the Python tree-sitter parser to support a comprehensive range of Python language constructs, enabling more accurate and detailed code analysis. Key improvements: - Added support for method definitions (instance, class, and static methods) - Added support for decorators on functions and classes - Added support for module-level variables and constants - Added support for async functions and methods - Added support for property getters/setters - Added support for type annotations in various contexts - Added support for dataclasses - Added support for nested functions and classes - Added support for generator functions - Added support for list/dict/set comprehensions - Added support for lambda functions - Added support for abstract base classes and methods The parser now handles Python's rich feature set more comprehensively, including special Python patterns like decorators, type annotations, and various comprehension types. This enables better code navigation, understanding, and analysis for Python codebases. Signed-off-by: Eric Wheeler commit 56d7cf6199f03ec5deabc5798ddac2338665cc23 Author: Eric Wheeler Date: Tue Apr 8 19:38:09 2025 -0700 feat: enhance Java tree-sitter parser with advanced language structures This enhancement significantly expands the Java parser's capabilities to recognize and parse a wide range of Java language constructs: - Added support for enum declarations and enum constants - Added support for annotation type declarations and elements - Added support for field declarations - Added support for constructor declarations - Added support for lambda expressions - Added support for inner and anonymous classes - Added support for type parameters (generics) - Added support for package and import declarations These improvements enable more comprehensive code analysis for Java projects, providing better definition extraction and navigation capabilities. Signed-off-by: Eric Wheeler commit 599c1849d03a9a41c950614e17e36c746f7567af Author: Eric Wheeler Date: Tue Apr 8 19:33:28 2025 -0700 feat: enhance Go tree-sitter parser with advanced language structures This enhancement significantly expands the Go parser's capabilities to recognize and extract a comprehensive set of language constructs: - Added support for struct and interface definitions with proper type identification - Implemented parsing for constant declarations (both single and in blocks) - Added support for variable declarations (both single and in blocks) - Added recognition of type aliases with proper distinction from regular types - Implemented special handling for init functions - Added support for anonymous functions, including nested function literals - Improved documentation and organization of query patterns These enhancements enable more accurate code navigation, better symbol extraction, and improved code intelligence for Go codebases. Signed-off-by: Eric Wheeler commit 749f793c08abb3488e7c02861cea1b83e8ba0d59 Author: Eric Wheeler Date: Tue Apr 8 19:27:00 2025 -0700 feat: enhance C++ tree-sitter parser with advanced language structures This enhancement significantly expands the C++ parser's capabilities to recognize and extract a wide range of modern C++ language constructs, improving code navigation and analysis. New supported language constructs include: - Union declarations and their members - Destructors and their implementations - Operator overloading (including stream operators) - Free-standing and namespace-scoped functions - Enum declarations (both traditional and scoped enum class) - Lambda expressions and their captures - Attributes and annotations - Method overrides with virtual/override specifiers - Exception specifications (noexcept) - Default parameters in function declarations - Variadic templates and parameter packs - Structured bindings (C++17) - Inline namespaces and nested namespace declarations - Template specializations and instantiations - Constructor implementations This enhancement provides more comprehensive code structure analysis for C++ codebases, particularly those using modern C++ features from C++11, C++14, and C++17 standards. Signed-off-by: Eric Wheeler commit 63358e794d84801e0a54fd8c928266835b8dadf2 Author: Eric Wheeler Date: Tue Apr 8 19:22:10 2025 -0700 feat: enhance TypeScript/TSX tree-sitter parser - Enhanced the Tree-Sitter parser for JavaScript/TypeScript with support for advanced language constructs - Modified the parser to exclude comments from the output - Consolidated sample code in tests for better maintainability Signed-off-by: Eric Wheeler commit b01615f122427a0cd135db7dc242c696bcfafd7e Author: Matt Rubens Date: Tue Apr 8 22:02:27 2025 -0400 Add the option to use a custom Host header for openai-compatible (#2399) commit 9f724bd36088801a2e3eeffb0975d51c2121f8e7 Author: Atlas Gong <68199735+atlasgong@users.noreply.github.com> Date: Tue Apr 8 15:05:04 2025 -0400 fix: z-index of highlight layer should be under mode/profile dropdowns (#2417) commit 6135d351dfc7c2c1818a30731d4fbbf14e2949be Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Tue Apr 8 08:51:58 2025 -0700 Changeset version bump (#2412) * changeset version bump * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens commit 75d17a523353a3fdcbf7c3ab0f986942477f8d0b Author: Matt Rubens Date: Tue Apr 8 11:46:32 2025 -0400 v3.11.10 (#2413) commit a4530eea5b57964c8e701c899d8f73c3ecf1d5e5 Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue Apr 8 11:40:53 2025 -0400 Update contributors list (#2402) docs: update contributors list [skip ci] Co-authored-by: cte commit 08b586334b67e99b0c08e7c5dc674899816db1d0 Author: Matt Rubens Date: Tue Apr 8 11:40:23 2025 -0400 Remove extra colon from rules content (#2409) commit 9ab4a881766f0ee7202c35495d0acb0f6013f8ba Author: Matt Rubens Date: Tue Apr 8 11:40:10 2025 -0400 Fix typo in diff prompt (#2410) commit e1f6eb625bb9ea26dde8b795332b35dae10e45df Author: Sam Hoang Van Date: Tue Apr 8 22:29:03 2025 +0700 feat: Add CommandOutputViewer component and integrate it into ChatRow (#2326) * feat: Add CommandOutputViewer component and integrate it into ChatRow * Remove unnecessary memo wrapper from CommandOutputViewer component commit 58149a083edf25fcb3d6c083002f8b30450b40cd Author: Ross McFarland Date: Tue Apr 8 08:21:54 2025 -0700 Clean up global `rateLimitSeconds` & fully shift to provider version (#2408) * Directly use provider rateLimitSeconds and remove uneeded default * remove a bunch of unused rateLimitSeconds references * rateLimitSettings field def in GlobalSettingsRecord isn't needed for migration commit dedd655c9e4d4697d3748a7623f2b6735018b640 Author: Taisuke Oe Date: Wed Apr 9 00:17:21 2025 +0900 Fix a bug not to read rule files properly, under nested `.roo/rules` directories (#2405) * fix .roo/rules/subdir/file path calculation * add changeset commit 4c81f7e167272669efa3afd42211a4f100e02e3a Author: Matt Rubens Date: Tue Apr 8 09:39:40 2025 -0400 Update CHANGELOG.md (#2406) commit 72a9e0bd39cab0d1286a88e08df0162babc0fdcf Author: Matt Rubens Date: Tue Apr 8 01:20:45 2025 -0400 Fix cache usage tracking for openai-compatible (#2401) commit 903f3b64a1fea76bc4e9eceb3d10c477ac24d3bf Author: Matt Rubens Date: Mon Apr 7 23:06:54 2025 -0400 Add custom instructions for zh-CN (#2381) * Add custom instructions for zh-CN * Updates from System233 commit 06e0fc6ee4c2f55422253c5b8c09262e63a0d508 Author: Matt Rubens Date: Mon Apr 7 18:33:25 2025 -0400 Update CHANGELOG.md (#2396) commit fbb7887f8dcb8aa2e1d4ece8315bca7b1ab493fa Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon Apr 7 17:38:38 2025 -0400 Update contributors list (#2338) docs: update contributors list [skip ci] Co-authored-by: mrubens commit 70a05d330214916e3201b2206b422eb2fdf1d81a Author: R00-B0T <110429663+R00-B0T@users.noreply.github.com> Date: Mon Apr 7 14:35:49 2025 -0700 Changeset version bump (#2394) * changeset version bump * Updating CHANGELOG.md format * Apply suggestions from code review * Update CHANGELOG.md * Update CHANGELOG.md --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: R00-B0T Co-authored-by: Matt Rubens commit 1ad8e9c7d3b9dab8b3e2cf7de7a2d4ed010550eb Author: Matt Rubens Date: Mon Apr 7 17:18:14 2025 -0400 v3.11.9 (#2393) commit 260fc3004397caf254e180fc17537e9731ca517a Author: Ross McFarland Date: Mon Apr 7 13:24:38 2025 -0700 feat(settings): Rate-limit setting updated to be per-profile (#2376) * Rate-limit setting updated to be per-profile * Correct rateLimitSeconds translations * Add missing d to rateLimitSecondsMigrate * Fix fr rate-limit translation commit af9e471c925f18070f35d2f06f76aaa82bf1aea4 Author: KJ7LNW <93454819+KJ7LNW@users.noreply.github.com> Date: Mon Apr 7 12:12:41 2025 -0700 dev: dynamic Vite port detection for webview development (#2339) Implements a solution for the Vite port collision issue that allows easier development of Roo across multiple instances of VSCode in different Roo repository directories. This may also fix crashes and strange behavior between multiple running instances that would otherwise create port conflicts. This is solved using the following automated process: - When Vite automatically selects an alternative port, a custom plugin automatically writes the port to a '.vite-port' file in the repository root - ClineProvider automatically reads the port from this file, falling back gracefully to port 5173 if the file doesn't exist - No user intervention is necessary as the entire process is handled automatically - Added detailed logging for debugging - Added .vite-port to .gitignore The extension now connects to the correct Vite development server port automatically, even when the default port (5173) is already in use. Signed-off-by: Eric Wheeler Co-authored-by: Eric Wheeler commit d0661fb0c7deddc27c8f8026ac3e445f959df6fb Author: Matt Rubens Date: Mon Apr 7 14:57:02 2025 -0400 Don't automatically convert types when parsing XML (#2389) * Don't automatically convert types when parsing XML * PR feedback commit d5aee1e1d875f4f61f4a9ec1c1237a883e4c5f0f Author: Matt Rubens Date: Mon Apr 7 14:24:23 2025 -0400 Add custom instructions for zh-TW (#2382) * Add custom instructions for zh-TW * Move custom instructions to a rules file for easier reading * PR feedback commit 320ef77d79898e146881cb61def3fdf7ad3e46c8 Author: Nico Bihan Date: Mon Apr 7 11:15:09 2025 -0500 Added to Vertex AI Provider gemini 2.5 Pro Preview (#2384) * Added Gemini 2.5 Pro model to Vertex AI Provider * Adds Gemini 2.5 Pro preview model Adds configuration for the new Gemini 2.5 Pro preview model, including its token limits, context window size, image support, and pricing information. commit 009faf349e5e657be1aa433fdf48b42713d533a7 Author: Matt Rubens Date: Mon Apr 7 12:14:04 2025 -0400 Move .roorules to .roo/rules/ (#2385) commit 580672ffc8bd187a5137c57b770d189f8fac2a27 Author: Yu SERIZAWA Date: Tue Apr 8 00:46:30 2025 +0900 feat: enhance rule file loading with .roo/rules directory support (#2354) * feat: enhance rule file loading with .roo/rules directory support - Introduced functions to safely read files and check for directory existence. - Added capability to read all text files from a specified directory in alphabetical order. - Updated `loadRuleFiles` to prioritize loading rules from a `.roo/rules/` directory, falling back to existing rule files if necessary. - Enhanced `addCustomInstructions` to support loading mode-specific rules from a `.roo/rules-{mode}/` directory, improving flexibility in rule management. This change improves the organization and retrieval of rule files, allowing for better modularity and maintainability. * Updated strings and translations * Add tests * Revert changes to system prompt translations * Fix path resolution * Make instruction structure clearer --------- Co-authored-by: Matt Rubens commit 0e4be83c356382bc39594f2e48199be5ab2e54f4 Author: Matt Rubens Date: Mon Apr 7 11:41:08 2025 -0400 Fixes to resumeTask and isTaskInHistory (#2380) commit cf74568f66d715b970b8ce6ab3198b561953ebba Author: Franciszek Piszcz Date: Mon Apr 7 17:19:47 2025 +0200 feat(RooCodeAPI): implement resumeTask and isTaskInHistory (#1672) commit f5a4b425daaa695a4d5a75012f38c82097dee179 Author: Marco Quinten Date: Mon Apr 7 20:35:14 2025 +0700 feat(browserTool): Implement hover action (#2368) * Implement hover action for the browser action tool * Update snapshots commit fff8fdd3f34e39f0e95af5e02979eb8228c0ddfa Author: Marco Quinten Date: Mon Apr 7 20:34:32 2025 +0700 feat(browserTool): Implement resize action (#2370) * Implement resize action for browser action tool * Update snapshots commit 88cac3c9d8ec23f324842e8efc8fe3a9caca9d94 Author: Aleksandr Kirillov <32141102+axkirillov@users.noreply.github.com> Date: Mon Apr 7 13:54:49 2025 +0200 feat: add command to focus Roo Code input field (#2369) * feat: add command to focus Roo Code input field * fixup! feat: add command to focus Roo Code input field * fixup! feat: add command to focus Roo Code input field commit 84c703e050937d27267ac3f650aafff635549251 Author: Kyle Tse Date: Mon Apr 7 03:17:49 2025 +0100 fix: Prevent unnecessary autoscroll when buttons appear (#1280) (#2334) * fix: Prevent unnecessary autoscroll when buttons appear (#1280) * Remove commented out code --------- Co-authored-by: Matt Rubens commit 0317374acb71face8bec0f01a365d99b8738877b Author: Matt Rubens Date: Sun Apr 6 16:04:34 2025 -0400 Move .clinerules to .roorules (#2357) commit 393688c58411ce13c23596028b1dc80a11fd09b1 Author: Matt Rubens Date: Sun Apr 6 14:31:14 2025 -0400 Add deep links to settings sections (#2355) commit 57d97319dccb2130eae92beb65a253993e07b1b7 Author: Greg Taylor Date: Sun Apr 6 08:19:30 2025 -0700 fix: persist settings on api.setConfiguration (#2341) Values weren't being saved to the settings store, preventing switching to newly created profiles. Co-authored-by: Greg Taylor --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- cline_docs/marketplace/README.md | 50 + .../implementation/01-architecture.md | 373 +++++ .../implementation/02-core-components.md | 244 ++++ .../implementation/03-data-structures.md | 218 +++ .../implementation/04-search-and-filter.md | 105 ++ .../implementation/05-ui-components.md | 398 ++++++ .../implementation/06-testing-strategy.md | 1170 ++++++++++++++++ .../implementation/07-extending.md | 926 ++++++++++++ .../marketplace/user-guide/01-introduction.md | 58 + .../user-guide/02-browsing-items.md | 148 ++ .../user-guide/03-searching-and-filtering.md | 140 ++ .../user-guide/04-working-with-details.md | 143 ++ .../user-guide/05-adding-packages.md | 226 +++ .../user-guide/06-adding-custom-sources.md | 152 ++ src/activate/registerCommands.ts | 5 + src/core/config/CustomModesManager.ts | 9 +- src/core/webview/ClineProvider.ts | 25 +- src/core/webview/marketplaceMessageHandler.ts | 321 +++++ src/core/webview/webviewMessageHandler.ts | 31 +- src/exports/roo-code.d.ts | 21 + src/exports/types.ts | 21 + src/i18n/locales/ca/marketplace.json | 100 ++ src/i18n/locales/de/marketplace.json | 100 ++ src/i18n/locales/en/marketplace.json | 104 ++ src/i18n/locales/es/marketplace.json | 100 ++ src/i18n/locales/fr/marketplace.json | 100 ++ src/i18n/locales/hi/marketplace.json | 97 ++ src/i18n/locales/it/marketplace.json | 100 ++ src/i18n/locales/ja/marketplace.json | 97 ++ src/i18n/locales/ko/marketplace.json | 97 ++ src/i18n/locales/pl/marketplace.json | 103 ++ src/i18n/locales/pt-BR/marketplace.json | 100 ++ src/i18n/locales/ru/marketplace.json | 105 ++ src/i18n/locales/tr/marketplace.json | 97 ++ src/i18n/locales/vi/marketplace.json | 97 ++ src/i18n/locales/zh-CN/marketplace.json | 97 ++ src/i18n/locales/zh-TW/marketplace.json | 97 ++ src/package.json | 15 + src/schemas/index.ts | 12 + src/services/marketplace/GitFetcher.ts | 317 +++++ .../marketplace/InstalledMetadataManager.ts | 205 +++ .../marketplace/MarketplaceManager.ts | 784 +++++++++++ src/services/marketplace/MetadataScanner.ts | 409 ++++++ .../marketplace/__tests__/GitFetcher.test.ts | 378 +++++ .../__tests__/GitUrlValidation.test.ts | 8 + .../__tests__/MarketplaceManager.test.ts | 764 ++++++++++ .../MarketplaceSourceValidation.test.ts | 237 ++++ .../MetadataScanner.external.test.ts | 41 + .../__tests__/MetadataScanner.test.ts | 157 +++ .../marketplace/__tests__/schemas.test.ts | 133 ++ src/services/marketplace/constants.ts | 22 + src/services/marketplace/index.ts | 4 + src/services/marketplace/schemas.ts | 178 +++ src/services/marketplace/types.ts | 159 +++ src/services/marketplace/utils.ts | 13 + src/shared/ExtensionMessage.ts | 12 + src/shared/MarketplaceValidation.ts | 254 ++++ src/shared/WebviewMessage.ts | 38 + .../__tests__/MarketplaceValidation.test.ts | 34 + src/utils/globalContext.ts | 13 + src/utils/url.ts | 8 + webview-ui/package.json | 2 + webview-ui/src/App.tsx | 13 +- webview-ui/src/__mocks__/lucide-react.ts | 3 + .../components/marketplace/InstallSidebar.tsx | 90 ++ .../marketplace/MarketplaceListView.tsx | 333 +++++ .../MarketplaceSourcesConfigView.tsx | 237 ++++ .../marketplace/MarketplaceView.tsx | 194 +++ .../MarketplaceViewStateManager.ts | 613 ++++++++ .../__tests__/InstallSidebar.test.tsx | 159 +++ .../__tests__/MarketplaceListView.test.tsx | 188 +++ .../MarketplaceSourcesConfig.test.tsx | 230 +++ .../MarketplaceViewStateManager.test.ts | 1236 +++++++++++++++++ .../components/ExpandableSection.tsx | 52 + .../components/MarketplaceItemActionsMenu.tsx | 111 ++ .../components/MarketplaceItemCard.tsx | 216 +++ .../marketplace/components/TypeGroup.tsx | 143 ++ .../__tests__/ExpandableSection.test.tsx | 119 ++ .../__tests__/MarketplaceItemCard.test.tsx | 218 +++ .../components/__tests__/TypeGroup.test.tsx | 122 ++ .../components/marketplace/useStateManager.ts | 44 + .../utils/__tests__/grouping.test.ts | 120 ++ .../components/marketplace/utils/grouping.ts | 90 ++ webview-ui/src/components/ui/accordion.tsx | 49 + .../src/context/ExtensionStateContext.tsx | 5 + .../src/i18n/locales/ca/marketplace.json | 91 ++ .../src/i18n/locales/de/marketplace.json | 91 ++ .../src/i18n/locales/en/marketplace.json | 94 ++ .../src/i18n/locales/es/marketplace.json | 91 ++ .../src/i18n/locales/fr/marketplace.json | 91 ++ .../src/i18n/locales/hi/marketplace.json | 91 ++ .../src/i18n/locales/it/marketplace.json | 91 ++ .../src/i18n/locales/ja/marketplace.json | 91 ++ .../src/i18n/locales/ko/marketplace.json | 91 ++ .../src/i18n/locales/pl/marketplace.json | 91 ++ .../src/i18n/locales/pt-BR/marketplace.json | 91 ++ .../src/i18n/locales/tr/marketplace.json | 91 ++ .../src/i18n/locales/vi/marketplace.json | 91 ++ .../src/i18n/locales/zh-CN/marketplace.json | 91 ++ .../src/i18n/locales/zh-TW/marketplace.json | 91 ++ webview-ui/src/i18n/test-utils.ts | 19 + webview-ui/src/index.css | 78 +- webview-ui/src/test/test-utils.tsx | 107 ++ 104 files changed, 16715 insertions(+), 16 deletions(-) create mode 100644 cline_docs/marketplace/README.md create mode 100644 cline_docs/marketplace/implementation/01-architecture.md create mode 100644 cline_docs/marketplace/implementation/02-core-components.md create mode 100644 cline_docs/marketplace/implementation/03-data-structures.md create mode 100644 cline_docs/marketplace/implementation/04-search-and-filter.md create mode 100644 cline_docs/marketplace/implementation/05-ui-components.md create mode 100644 cline_docs/marketplace/implementation/06-testing-strategy.md create mode 100644 cline_docs/marketplace/implementation/07-extending.md create mode 100644 cline_docs/marketplace/user-guide/01-introduction.md create mode 100644 cline_docs/marketplace/user-guide/02-browsing-items.md create mode 100644 cline_docs/marketplace/user-guide/03-searching-and-filtering.md create mode 100644 cline_docs/marketplace/user-guide/04-working-with-details.md create mode 100644 cline_docs/marketplace/user-guide/05-adding-packages.md create mode 100644 cline_docs/marketplace/user-guide/06-adding-custom-sources.md create mode 100644 src/core/webview/marketplaceMessageHandler.ts create mode 100644 src/i18n/locales/ca/marketplace.json create mode 100644 src/i18n/locales/de/marketplace.json create mode 100644 src/i18n/locales/en/marketplace.json create mode 100644 src/i18n/locales/es/marketplace.json create mode 100644 src/i18n/locales/fr/marketplace.json create mode 100644 src/i18n/locales/hi/marketplace.json create mode 100644 src/i18n/locales/it/marketplace.json create mode 100644 src/i18n/locales/ja/marketplace.json create mode 100644 src/i18n/locales/ko/marketplace.json create mode 100644 src/i18n/locales/pl/marketplace.json create mode 100644 src/i18n/locales/pt-BR/marketplace.json create mode 100644 src/i18n/locales/ru/marketplace.json create mode 100644 src/i18n/locales/tr/marketplace.json create mode 100644 src/i18n/locales/vi/marketplace.json create mode 100644 src/i18n/locales/zh-CN/marketplace.json create mode 100644 src/i18n/locales/zh-TW/marketplace.json create mode 100644 src/services/marketplace/GitFetcher.ts create mode 100644 src/services/marketplace/InstalledMetadataManager.ts create mode 100644 src/services/marketplace/MarketplaceManager.ts create mode 100644 src/services/marketplace/MetadataScanner.ts create mode 100644 src/services/marketplace/__tests__/GitFetcher.test.ts create mode 100644 src/services/marketplace/__tests__/GitUrlValidation.test.ts create mode 100644 src/services/marketplace/__tests__/MarketplaceManager.test.ts create mode 100644 src/services/marketplace/__tests__/MarketplaceSourceValidation.test.ts create mode 100644 src/services/marketplace/__tests__/MetadataScanner.external.test.ts create mode 100644 src/services/marketplace/__tests__/MetadataScanner.test.ts create mode 100644 src/services/marketplace/__tests__/schemas.test.ts create mode 100644 src/services/marketplace/constants.ts create mode 100644 src/services/marketplace/index.ts create mode 100644 src/services/marketplace/schemas.ts create mode 100644 src/services/marketplace/types.ts create mode 100644 src/services/marketplace/utils.ts create mode 100644 src/shared/MarketplaceValidation.ts create mode 100644 src/shared/__tests__/MarketplaceValidation.test.ts create mode 100644 src/utils/globalContext.ts create mode 100644 src/utils/url.ts create mode 100644 webview-ui/src/components/marketplace/InstallSidebar.tsx create mode 100644 webview-ui/src/components/marketplace/MarketplaceListView.tsx create mode 100644 webview-ui/src/components/marketplace/MarketplaceSourcesConfigView.tsx create mode 100644 webview-ui/src/components/marketplace/MarketplaceView.tsx create mode 100644 webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts create mode 100644 webview-ui/src/components/marketplace/__tests__/InstallSidebar.test.tsx create mode 100644 webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx create mode 100644 webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx create mode 100644 webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts create mode 100644 webview-ui/src/components/marketplace/components/ExpandableSection.tsx create mode 100644 webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx create mode 100644 webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx create mode 100644 webview-ui/src/components/marketplace/components/TypeGroup.tsx create mode 100644 webview-ui/src/components/marketplace/components/__tests__/ExpandableSection.test.tsx create mode 100644 webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx create mode 100644 webview-ui/src/components/marketplace/components/__tests__/TypeGroup.test.tsx create mode 100644 webview-ui/src/components/marketplace/useStateManager.ts create mode 100644 webview-ui/src/components/marketplace/utils/__tests__/grouping.test.ts create mode 100644 webview-ui/src/components/marketplace/utils/grouping.ts create mode 100644 webview-ui/src/components/ui/accordion.tsx create mode 100644 webview-ui/src/i18n/locales/ca/marketplace.json create mode 100644 webview-ui/src/i18n/locales/de/marketplace.json create mode 100644 webview-ui/src/i18n/locales/en/marketplace.json create mode 100644 webview-ui/src/i18n/locales/es/marketplace.json create mode 100644 webview-ui/src/i18n/locales/fr/marketplace.json create mode 100644 webview-ui/src/i18n/locales/hi/marketplace.json create mode 100644 webview-ui/src/i18n/locales/it/marketplace.json create mode 100644 webview-ui/src/i18n/locales/ja/marketplace.json create mode 100644 webview-ui/src/i18n/locales/ko/marketplace.json create mode 100644 webview-ui/src/i18n/locales/pl/marketplace.json create mode 100644 webview-ui/src/i18n/locales/pt-BR/marketplace.json create mode 100644 webview-ui/src/i18n/locales/tr/marketplace.json create mode 100644 webview-ui/src/i18n/locales/vi/marketplace.json create mode 100644 webview-ui/src/i18n/locales/zh-CN/marketplace.json create mode 100644 webview-ui/src/i18n/locales/zh-TW/marketplace.json create mode 100644 webview-ui/src/test/test-utils.tsx diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5bfc08f80f..ecf0e7bae3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -85,4 +85,4 @@ body: attributes: label: 📄 Relevant Logs or Errors (Optional) description: Paste API logs, terminal output, or errors here. Use triple backticks (```) for code formatting. - render: shell \ No newline at end of file + render: shell diff --git a/cline_docs/marketplace/README.md b/cline_docs/marketplace/README.md new file mode 100644 index 0000000000..6e43e25723 --- /dev/null +++ b/cline_docs/marketplace/README.md @@ -0,0 +1,50 @@ +# Marketplace Documentation + +This directory contains comprehensive documentation for the Roo Code Marketplace, including both user guides and implementation details. + +## Documentation Structure + +### User Guide + +The user guide provides end-user documentation for using the Marketplace: + +1. [Introduction to Marketplace](./user-guide/01-introduction.md) - Overview and purpose of the Marketplace +2. [Browsing Items](./user-guide/02-browsing-items.md) - Understanding the interface and navigating items +3. [Searching and Filtering](./user-guide/03-searching-and-filtering.md) - Using search and filters to find items +4. [Working with Package Details](./user-guide/04-working-with-details.md) - Exploring package details and items +5. [Adding Packages](./user-guide/05-adding-packages.md) - Creating and contributing your own items +6. [Adding Custom Sources](./user-guide/06-adding-custom-sources.md) - Setting up and managing custom sources + +### Implementation Documentation + +The implementation documentation provides technical details for developers: + +1. [Architecture](./implementation/01-architecture.md) - High-level architecture of the Marketplace +2. [Core Components](./implementation/02-core-components.md) - Key components and their responsibilities +3. [Data Structures](./implementation/03-data-structures.md) - Data models and structures used in the Marketplace +4. [Search and Filter](./implementation/04-search-and-filter.md) - Implementation of search and filtering functionality + +## Key Features + +The Marketplace provides the following key features: + +- **Component Discovery**: Browse and search for items +- **Item Management**: Add/update/remove items to your environment +- **Custom Sources**: Add your own repositories of team or private Marketplaces +- **Localization Support**: View items in your preferred language +- **Filtering**: Filter items by type, search term, and tags + +## Default Marketplace Repository + +The default Marketplace repository is located at: +[https://github.com/RooVetGit/Roo-Code-Marketplace](https://github.com/RooVetGit/Roo-Code-Marketplace) + +## Contributing + +To contribute to the Marketplace documentation: + +1. Make your changes to the relevant markdown files +2. Ensure that your changes are accurate and consistent with the actual implementation +3. Submit a pull request with your changes + +For code changes to the Marketplace itself, please refer to the main [CONTRIBUTING.md](../../CONTRIBUTING.md) file. diff --git a/cline_docs/marketplace/implementation/01-architecture.md b/cline_docs/marketplace/implementation/01-architecture.md new file mode 100644 index 0000000000..9fbce20a20 --- /dev/null +++ b/cline_docs/marketplace/implementation/01-architecture.md @@ -0,0 +1,373 @@ +# Marketplace Architecture + +This document provides a comprehensive overview of the Marketplace's architecture, including its components, interactions, and data flow. + +## System Overview + +The Marketplace is built on a modular architecture that separates concerns between data management, UI rendering, and user interactions. The system consists of several key components that work together to provide a seamless experience for discovering, browsing, and managing items. + +### High-Level Architecture + +```mermaid +graph TD + User[User] -->|Interacts with| UI[Marketplace UI] + UI -->|Sends messages| MH[Message Handler] + MH -->|Processes requests| PM[MarketplaceManager] + PM -->|Validates sources| PSV[MarketplaceSourceValidation] + PM -->|Fetches repos| GF[GitFetcher] + GF -->|Scans metadata| MS[MetadataScanner] + MS -->|Reads| FS[File System / Git Repositories] + PM -->|Returns filtered data| MH + MH -->|Updates state| UI + UI -->|Displays| User +``` + +The architecture follows a message-based pattern where: + +1. The UI sends messages to the backend through a message handler +2. The backend processes these messages and returns results +3. The UI updates based on the returned data +4. Components are loosely coupled through message passing + +## Component Interactions + +The Marketplace components interact through a well-defined message flow: + +### Core Interaction Patterns + +1. **Data Loading**: + + - GitFetcher handles repository cloning and updates + - MetadataScanner loads item data from repositories + - MarketplaceManager manages caching and concurrency + - UI requests data through the message handler + +2. **Filtering and Search**: + + - UI sends filter/search criteria to the backend + - MarketplaceManager applies filters with match info + - Filtered results are returned to the UI + - State manager handles view-level filtering + +3. **Source Management**: + - UI sends source management commands + - MarketplaceManager coordinates with GitFetcher + - Cache is managed with timeout protection + - Sources are processed with concurrency control + +## Data Flow Diagram + +The following diagram illustrates the data flow through the Marketplace system: + +```mermaid +graph LR + subgraph Sources + GR[Git Repositories] + FS[File System] + end + + subgraph Backend + GF[GitFetcher] + MS[MetadataScanner] + PM[MarketplaceManager] + MH[Message Handler] + end + + subgraph Frontend + UI[UI Components] + State[State Management] + end + + GR -->|Clone/Pull| GF + FS -->|Cache| GF + GF -->|Metadata| MS + MS -->|Parsed Data| PM + PM -->|Cached Items| PM + UI -->|User Actions| MH + MH -->|Messages| PM + PM -->|Filtered Data| MH + MH -->|Updates| State + State -->|Renders| UI +``` + +## Sequence Diagrams + +### Item Loading Sequence + +The following sequence diagram shows how items are loaded from sources: + +```mermaid +sequenceDiagram + participant User + participant UI as UI Components + participant MH as Message Handler + participant PM as MarketplaceManager + participant GF as GitFetcher + participant MS as MetadataScanner + participant FS as File System/Git + + User->>UI: Open Marketplace + UI->>MH: Send init message + MH->>PM: Initialize + PM->>GF: Request repository data + GF->>FS: Clone/pull repository + GF->>MS: Request metadata scan + MS->>FS: Read repository data + FS-->>MS: Return raw data + MS-->>GF: Return parsed metadata + GF-->>PM: Return repository data + PM-->>MH: Return initial items + MH-->>UI: Update with items + UI-->>User: Display items +``` + +### Search and Filter Sequence + +This sequence diagram illustrates the search and filter process: + +```mermaid +sequenceDiagram + participant User + participant UI as UI Components + participant State as State Manager + participant MH as Message Handler + participant PM as MarketplaceManager + + User->>UI: Enter search term + UI->>State: Update filters + State->>MH: Send search message + MH->>PM: Apply search filter + PM->>PM: Filter items with match info + PM-->>MH: Return filtered items + MH-->>State: Update with filtered items + State-->>UI: Update view + UI-->>User: Display filtered results + + User->>UI: Select type filter + UI->>State: Update type filter + State->>MH: Send type filter message + MH->>PM: Apply type filter + PM->>PM: Filter by type with match info + PM-->>MH: Return type-filtered items + MH-->>State: Update filtered items + State-->>UI: Update view + UI-->>User: Display type-filtered results +``` + +## Class Diagrams + +### Core Classes + +The following class diagram shows the main classes in the Marketplace system: + +```mermaid +classDiagram + class MarketplaceManager { + -currentItems: MarketplaceItem[] + -cache: Map + -gitFetcher: GitFetcher + -activeSourceOperations: Set + +getMarketplaceItems(): MarketplaceItem[] + +filterItems(filters): MarketplaceItem[] + +sortItems(sortBy, order): MarketplaceItem[] + +refreshRepository(url): void + -queueOperation(operation): void + -validateSources(sources): ValidationError[] + } + + class MarketplaceSourceValidation { + +validateSourceUrl(url): ValidationError[] + +validateSourceName(name): ValidationError[] + +validateSourceDuplicates(sources): ValidationError[] + +validateSource(source): ValidationError[] + +validateSources(sources): ValidationError[] + -isValidGitRepositoryUrl(url): boolean + } + + class GitFetcher { + -cacheDir: string + -metadataScanner: MetadataScanner + +fetchRepository(url): MarketplaceRepository + -cloneOrPullRepository(url): void + -validateRegistryStructure(dir): void + -parseRepositoryMetadata(dir): RepositoryMetadata + } + + class MetadataScanner { + -git: SimpleGit + +scanDirectory(path): MarketplaceItem[] + +parseMetadata(file): ComponentMetadata + -buildComponentHierarchy(items): MarketplaceItem[] + } + + class MarketplaceViewStateManager { + -state: ViewState + -stateChangeHandlers: Set + -fetchTimeoutId: NodeJS.Timeout + -sourcesModified: boolean + +initialize(): void + +onStateChange(handler): () => void + +cleanup(): void + +getState(): ViewState + +transition(transition): Promise + -notifyStateChange(): void + -clearFetchTimeout(): void + -isFilterActive(): boolean + -filterItems(items): MarketplaceItem[] + -sortItems(items): MarketplaceItem[] + +handleMessage(message): Promise + } + + MarketplaceManager --> GitFetcher: uses + MarketplaceManager --> MarketplaceSourceValidation: uses + GitFetcher --> MetadataScanner: uses + MarketplaceManager --> MarketplaceViewStateManager: updates +``` + +## Component Responsibilities + +### Backend Components + +1. **GitFetcher** + + - Handles Git repository operations + - Manages repository caching + - Validates repository structure + - Coordinates with MetadataScanner + +2. **MetadataScanner** + + - Scans directories and repositories + - Parses YAML metadata files + - Builds component hierarchies + - Handles file system operations + +3. **MarketplaceManager** + + - Manages concurrent operations + - Handles caching with timeout protection + - Coordinates repository operations + - Provides filtering and sorting + +4. **marketplaceMessageHandler** + - Routes messages between UI and backend + - Processes commands from the UI + - Returns data and status updates + - Handles error conditions + +### Frontend Components + +1. **MarketplaceViewStateManager** + + - Manages frontend state and backend synchronization + - Handles state transitions and message processing + - Manages filtering, sorting, and view preferences + - Coordinates with backend state + - Handles timeout protection for operations + - Manages source modification tracking + - Provides state change subscriptions + +2. **MarketplaceSourceValidation** + + - Validates Git repository URLs for any domain + - Validates source names and configurations + - Detects duplicate sources (case-insensitive) + - Provides structured validation errors + - Supports multiple Git protocols (HTTPS, SSH, Git) + +3. **MarketplaceItemCard** + + - Displays item information + - Handles tag interactions + - Manages expandable sections + - Shows match highlights + - Handle item actions. + +4. **ExpandableSection** + + - Provides collapsible sections + - Manages expand/collapse state + - Handles animations + - Shows section metadata + +5. **TypeGroup** + - Groups items by type + - Formats item lists + - Highlights search matches + - Maintains consistent styling + +## Performance Considerations + +The Marketplace architecture addresses several performance challenges: + +1. **Concurrency Control**: + + - Source operations are locked to prevent conflicts + - Operations are queued during metadata scanning + - Cache timeouts prevent hanging operations + - Repository operations are atomic + +2. **Efficient Caching**: + + - Repository data is cached with expiry + - Cache is cleaned up automatically + - Forced refresh available when needed + - Cache directories managed efficiently + +3. **Smart Filtering**: + - Match info tracks filter matches + - Filtering happens at multiple levels + - View state optimizes re-renders + - Search is case-insensitive and normalized + +## Error Handling + +The architecture includes robust error handling: + +1. **Repository Operations**: + + - Git lock files are cleaned up + - Failed clones are retried + - Corrupt repositories are re-cloned + - Network timeouts are handled + +2. **Data Processing**: + + - Invalid metadata is gracefully handled + - Missing files are reported clearly + - Parse errors preserve partial data + - Type validation ensures consistency + +3. **State Management**: + - Invalid filters are normalized + - Sort operations handle missing data + - View updates are atomic + - Error states are preserved + +## Extensibility Points + +The Marketplace architecture is designed for extensibility: + +1. **Repository Sources**: + + - Support for multiple Git providers + - Custom repository validation + - Flexible metadata formats + - Localization support + +2. **Filtering System**: + + - Custom filter types + - Extensible match info + - Flexible sort options + - View state customization + +3. **UI Components**: + - Custom item renderers + - Flexible layout system + - Theme integration + - Accessibility support + +--- + +**Previous**: [Adding Custom Item Sources](../user-guide/06-adding-custom-sources.md) | **Next**: [Core Components](./02-core-components.md) diff --git a/cline_docs/marketplace/implementation/02-core-components.md b/cline_docs/marketplace/implementation/02-core-components.md new file mode 100644 index 0000000000..3aa8d96726 --- /dev/null +++ b/cline_docs/marketplace/implementation/02-core-components.md @@ -0,0 +1,244 @@ +# Core Components + +This document provides detailed information about the core components of the Marketplace system, their responsibilities, implementation details, and interactions. + +## GitFetcher + +The GitFetcher is responsible for managing Git repository operations, including cloning, pulling, and caching repository data. + +### Responsibilities + +- Cloning and updating Git repositories +- Managing repository cache +- Validating repository structure +- Coordinating with MetadataScanner +- Handling repository timeouts and errors + +### Implementation Details + +[/src/services/marketplace/GitFetcher.ts](/src/services/marketplace/GitFetcher.ts) + +### Key Algorithms + +#### Repository Management + +The repository management process includes: + +1. **Cache Management**: + + - Check if repository exists in cache + - Validate cache freshness + - Clean up stale cache entries + - Handle cache directory creation + +2. **Repository Operations**: + + - Clone new repositories + - Pull updates for existing repos + - Handle git lock files + - Clean up failed operations + +3. **Error Recovery**: + - Handle network timeouts + - Recover from corrupt repositories + - Clean up partial clones + - Retry failed operations + +## MetadataScanner + +The MetadataScanner is responsible for reading and parsing item metadata from repositories. + +### Responsibilities + +- Scanning directories for item metadata files +- Parsing YAML metadata into structured objects +- Building component hierarchies +- Supporting localized metadata +- Validating metadata structure + +### Implementation Details + +[/src/services/marketplace/MetadataScanner.ts](/src/services/marketplace/MetadataScanner.ts) + +## MarketplaceManager + +The MarketplaceManager is the central component that manages marketplace data, caching, and operations. + +### Responsibilities + +- Managing concurrent operations +- Handling repository caching +- Coordinating with GitFetcher +- Applying filters and sorting +- Managing registry sources + +### Implementation Details + +[/src/services/marketplace/MarketplaceManager.ts](/src/services/marketplace/MarketplaceManager.ts) + +### Key Algorithms + +#### Concurrency Control + +The manager implements sophisticated concurrency control: + +1. **Operation Queueing**: + + - Queue operations during active scans + - Process operations sequentially + - Handle operation dependencies + - Maintain operation order + +2. **Source Locking**: + + - Lock sources during operations + - Prevent concurrent source access + - Handle lock timeouts + - Clean up stale locks + +3. **Cache Management**: + - Implement cache expiration + - Handle cache invalidation + - Clean up unused cache + - Optimize cache storage + +#### Advanced Filtering + +The filtering system provides rich functionality: + +1. **Multi-level Filtering**: + + - Filter parent items + - Filter subcomponents + - Handle item-specific logic + - Track match information + +2. **Match Information**: + - Track match reasons + - Handle partial matches + - Support highlighting + - Maintain match context + +## MarketplaceValidation + +The MarketplaceValidation component handles validation of marketplace sources and their configurations. + +### Responsibilities + +- Validating Git repository URLs for any domain +- Validating source names and configurations +- Detecting duplicate sources +- Providing structured validation errors +- Supporting multiple Git protocols + +### Implementation Details + +[/src/shared/MarketplaceValidation.ts](/src/shared/MarketplaceValidation.ts) + +### Key Algorithms + +#### URL Validation + +The URL validation system supports: + +1. **Protocol Validation**: + + - HTTPS URLs + - SSH URLs + - Git protocol URLs + - Custom domains and ports + +2. **Domain Validation**: + + - Any valid domain name + - IP addresses + - Localhost for testing + - Internal company domains + +3. **Path Validation**: + - Username/organization + - Repository name + - Optional .git suffix + - Subpath support + +## MarketplaceViewStateManager + +The MarketplaceViewStateManager manages frontend state and synchronization with the backend. + +### Responsibilities + +- Managing frontend state transitions +- Handling message processing +- Managing timeouts and retries +- Coordinating with backend state +- Providing state change subscriptions +- Managing source modification tracking +- Handling filtering and sorting + +### Implementation Details + +[/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts](/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts) + +## Component Integration + +The components work together through well-defined interfaces: + +### Data Flow + +1. **Repository Operations**: + + - MarketplaceManager validates sources with MarketplaceValidation + - MarketplaceManager coordinates with GitFetcher + - GitFetcher manages repository state + - MetadataScanner processes repository content + - Results flow back to MarketplaceManager + +2. **State Management**: + + - MarketplaceManager maintains backend state + - ViewStateManager handles UI state transitions + - ViewStateManager processes messages + - State changes notify subscribers + - Components react to state changes + - Timeout protection ensures responsiveness + +3. **User Interactions**: + - UI events trigger state updates + - ViewStateManager processes changes + - Changes propagate to backend + - Results update UI state + +## Performance Optimizations + +The system includes several optimizations: + +1. **Concurrent Operations**: + + - Operation queueing + - Source locking + - Parallel processing where safe + - Resource management + +2. **Efficient Caching**: + + - Multi-level cache + - Cache invalidation + - Lazy loading + - Cache cleanup + +3. **Smart Filtering**: + + - Optimized algorithms + - Match tracking + - Incremental updates + - Result caching + +4. **State Management**: + - Minimal updates + - State normalization + - Change batching + - Update optimization + +--- + +**Previous**: [Marketplace Architecture](./01-architecture.md) | **Next**: [Data Structures](./03-data-structures.md) diff --git a/cline_docs/marketplace/implementation/03-data-structures.md b/cline_docs/marketplace/implementation/03-data-structures.md new file mode 100644 index 0000000000..f1db63a498 --- /dev/null +++ b/cline_docs/marketplace/implementation/03-data-structures.md @@ -0,0 +1,218 @@ +# Data Structures + +This document details the key data structures used in the Marketplace, including their definitions, relationships, and usage patterns. + +## Item Types + +The Marketplace uses a type system to categorize different kinds of items: + +### MarketplaceItemType Enumeration + +[/src/services/marketplace/types.ts](/src/services/marketplace/types.ts) + +These types represent the different kinds of components that can be managed by the Marketplace: + +1. **mode**: AI assistant personalities with specialized capabilities +2. **prompt**: Pre-configured instructions for specific tasks +3. **mcp**: Model Context Protocol servers that provide additional functionality +4. **package**: Collections of items (multiple modes, mcps,..., like `roo-commander`) + +## Core Data Structures + +### MarketplaceRepository + +```typescript +/** + * Represents a repository with its metadata and items + */ +export interface MarketplaceRepository { + metadata: RepositoryMetadata + items: MarketplaceItem[] + url: string + defaultBranch: string + error?: string +} +``` + +This interface represents a complete repository: + +- **metadata**: The repository metadata +- **items**: Array of items in the repository +- **url**: The URL to the repository +- **defaultBranch**: The default Git branch (e.g., "main") +- **error**: Optional error message if there was a problem + +### MarketplaceItem + +[/src/services/marketplace/types.ts](/src/services/marketplace/types.ts) + +Key changes: + +- Added **defaultBranch** field for Git branch tracking +- Enhanced **matchInfo** structure for better filtering +- Improved subcomponent handling + +### MatchInfo + +[/src/services/marketplace/types.ts](/src/services/marketplace/types.ts) + +Enhanced match tracking: + +- Added **typeMatch** for component type filtering +- More detailed match reasons +- Support for subcomponent matching + +## State Management Structures + +### ValidationError + +[/src/shared/MarketplaceValidation.ts](/src/shared/MarketplaceValidation.ts) + +Used for structured validation errors: + +- **field**: The field that failed validation (e.g., "url", "name") +- **message**: Human-readable error message + +### ViewState + +[/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts](/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts) + +Manages UI state: + +- **allItems**: All available items +- **displayItems**: Currently filtered/displayed items +- **isFetching**: Loading state indicator +- **activeTab**: Current view tab +- **refreshingUrls**: Sources being refreshed +- **sources**: Marketplace sources +- **filters**: Active filters +- **sortConfig**: Sort configuration + +### ViewStateTransition + +[/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts](/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts) + +Defines state transitions: + +- Operation types +- Optional payloads +- Type-safe transitions + +### Filters + +[/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts](/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts) + +Enhanced filtering: + +- Component type filtering +- Text search +- Tag-based filtering + +## Metadata Interfaces + +### BaseMetadata + +[/src/services/marketplace/types.ts](/src/services/marketplace/types.ts) + +Common metadata properties: + +- **name**: Display name +- **description**: Detailed explanation +- **version**: Semantic version +- **tags**: Optional keywords + +### ComponentMetadata + +[/src/services/marketplace/types.ts](/src/services/marketplace/types.ts) + +Added: + +- **type** field for item component type + +### PackageMetadata + +[/src/services/marketplace/types.ts](/src/services/marketplace/types.ts) + +Enhanced with: + +- Subcomponent tracking + +## Source Management + +### MarketplaceSource + +[/src/services/marketplace/types.ts](/src/services/marketplace/types.ts) + +## Message Structures + +> TBA + +## Data Validation + +### Metadata Validation + +[/src/services/marketplace/schemas.ts](/src/services/marketplace/schemas.ts) + +### URL Validation + +[/src/shared/MarketplaceValidation.ts](/src/shared/MarketplaceValidation.ts) + +Supports: + +- Any valid domain name +- Multiple Git protocols +- Optional .git suffix +- Subpath components + +## Data Flow + +The Marketplace transforms data through several stages: + +1. **Repository Level**: + + - Clone/pull Git repositories + - Parse metadata files + - Build component hierarchy + +2. **Cache Level**: + + - Store repository data + - Track timestamps + - Handle expiration + +3. **View Level**: + - Apply filters + - Sort items + - Track matches + - Manage UI state + +## Data Relationships + +### Component Hierarchy + +``` +Repository +├── Metadata +└── Items + ├── Package + │ ├── Mode + │ ├── MCP + │ └── Prompt + └── Standalone Components (Modes, MCP, Prompts) +``` + +### State Flow + +``` +Git Repository → Cache → Marketplace → ViewState → UI +``` + +### Filter Chain + +``` +Raw Items → Type Filter → Search Filter → Tag Filter → Sorted Results +``` + +--- + +**Previous**: [Core Components](./02-core-components.md) | **Next**: [Search and Filter Implementation](./04-search-and-filter.md) diff --git a/cline_docs/marketplace/implementation/04-search-and-filter.md b/cline_docs/marketplace/implementation/04-search-and-filter.md new file mode 100644 index 0000000000..9d06976eee --- /dev/null +++ b/cline_docs/marketplace/implementation/04-search-and-filter.md @@ -0,0 +1,105 @@ +# Search and Filter Implementation + +This document details the implementation of search and filtering functionality in the Marketplace, including algorithms, optimization techniques, and performance considerations. + +## Core Filter System + +The Marketplace implements a comprehensive filtering system that handles multiple filter types, concurrent operations, and detailed match tracking. + +### Filter Implementation + +[/src/services/marketplace/MarketplaceManager.ts](/src/services/marketplace/MarketplaceManager.ts) + +## Sort System + +The Marketplace implements flexible sorting with subcomponent support: + +[/src/services/marketplace/MarketplaceManager.ts](/src/services/marketplace/MarketplaceManager.ts) + +## State Management Integration + +The filtering system integrates with the state management through state transitions: + +[/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts](/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts) + +## Performance Optimizations + +### Concurrent Operation Handling + +[/src/services/marketplace/MarketplaceManager.ts](/src/services/marketplace/MarketplaceManager.ts) + +### Filter Optimizations + +1. **Early Termination**: + + - Returns as soon as any field matches + - Avoids unnecessary checks + - Handles empty filters efficiently + +2. **Efficient String Operations**: + + - Normalizes text once + - Uses native string methods + - Avoids regex for simple matches + +3. **State Management**: + - State transitions for predictable updates + - Subscriber pattern for state changes + - Separation of all items and display items + - Backend-driven filtering + - Optimistic UI updates + - Efficient state synchronization + +## Testing Strategy + +```typescript +describe("Filter System", () => { + describe("Match Tracking", () => { + it("should track type matches", () => { + const result = filterItems([testItem], { type: "mode" }) + expect(result[0].matchInfo.matchReason.typeMatch).toBe(true) + }) + + it("should track subcomponent matches", () => { + const result = filterItems([testPack], { search: "test" }) + const subItem = result[0].items![0] + expect(subItem.matchInfo.matched).toBe(true) + }) + }) + + describe("Sort System", () => { + it("should sort subcomponents", () => { + const result = sortItems([testPack], "name", "asc", true) + expect(result[0].items).toBeSorted((a, b) => a.metadata.name.localeCompare(b.metadata.name)) + }) + }) +}) +``` + +## Error Handling + +The system includes robust error handling: + +1. **Filter Errors**: + + - Invalid filter types + - Malformed search terms + - Missing metadata + +2. **Sort Errors**: + + - Invalid sort fields + - Missing sort values + - Type mismatches + +3. **State Errors**: + - Invalid state transitions + - Message handling errors + - State synchronization issues + - Timeout handling + - Source modification tracking + - Filter validation errors + +--- + +**Previous**: [Data Structures](./03-data-structures.md) | **Next**: [UI Component Design](./05-ui-components.md) diff --git a/cline_docs/marketplace/implementation/05-ui-components.md b/cline_docs/marketplace/implementation/05-ui-components.md new file mode 100644 index 0000000000..435c2f77ce --- /dev/null +++ b/cline_docs/marketplace/implementation/05-ui-components.md @@ -0,0 +1,398 @@ +# UI Component Design + +This document details the design and implementation of the Marketplace's UI components, including their structure, styling, interactions, and accessibility features. + +## MarketplaceView + +The MarketplaceView is the main container component that manages the overall marketplace interface. + +### Component Structure + +[/webview-ui/src/components/marketplace/MarketplaceView.tsx](/webview-ui/src/components/marketplace/MarketplaceView.tsx) + +### State Management Integration + +The component uses the MarketplaceViewStateManager through the useStateManager hook: + +```tsx +const [state, manager] = useStateManager() +``` + +Key features: + +- Manages tab state (browse/sources) +- Handles source configuration +- Coordinates filtering and sorting +- Manages loading states +- Handles source validation + +## MarketplaceItemCard + +The MarketplaceItemCard is the primary component for displaying item information in the UI. + +### Component Structure + +[/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx](/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx) + +### Design Considerations + +1. **Visual Hierarchy**: + + - Clear distinction between header, content, and footer + - Type badge stands out with color coding + - Important information is emphasized with typography + +2. **Interactive Elements**: + + - Tags are clickable for filtering + - External link button for source access + - Expandable details section for subcomponents + +3. **Information Density**: + + - Balanced display of essential information + - Optional elements only shown when available + - Expandable section for additional details + +4. **VSCode Integration**: + - Uses VSCode theme variables for colors + - Matches VSCode UI patterns + - Integrates with VSCode messaging system + +## ExpandableSection + +The ExpandableSection component provides a collapsible container for content that doesn't need to be visible at all times. + +### Component Structure + +[/webview-ui/src/components/marketplace/components/ExpandableSection.tsx](/webview-ui/src/components/marketplace/components/ExpandableSection.tsx) + +### Design Considerations + +1. **Animation**: + + - Smooth height transition for expand/collapse + - Opacity change for better visual feedback + - Chevron icon rotation for state indication + +2. **Accessibility**: + + - Proper ARIA attributes for screen readers + - Keyboard navigation support + - Clear visual indication of interactive state + +3. **Flexibility**: + + - Accepts any content as children + - Optional badge for additional information + - Customizable through className prop + +4. **State Management**: + - Internal state for expanded/collapsed + - Can be controlled through defaultExpanded prop + - Preserves state during component lifecycle + +## TypeGroup + +The TypeGroup component displays a collection of items of the same type, with special handling for search matches. + +### Component Structure + +[/webview-ui/src/components/marketplace/components/TypeGroup.tsx](/webview-ui/src/components/marketplace/components/TypeGroup.tsx) + +### Design Considerations + +1. **List Presentation**: + + - Ordered list with automatic numbering + - Clear type heading for context + - Consistent spacing for readability + +2. **Search Match Highlighting**: + + - Visual distinction for matching items + - "match" badge for quick identification + - Color change for matched text + +3. **Information Display**: + + - Name and description clearly separated + - Tooltip shows path information on hover + - Truncation for very long descriptions + +4. **Empty State Handling**: + - Returns null when no items are present + - Avoids rendering empty containers + - Prevents unnecessary UI elements + +## Source Configuration Components + +The Marketplace includes components for managing item sources. + +[/webview-ui/src/components/marketplace/MarketplaceView.tsx](/webview-ui/src/components/marketplace/MarketplaceView.tsx) + +## Filter Components + +The Marketplace includes components for filtering and searching. + +[/webview-ui/src/components/marketplace/MarketplaceView.tsx](/webview-ui/src/components/marketplace/MarketplaceView.tsx) + +### TypeFilterGroup + +[/webview-ui/src/components/marketplace/MarketplaceView.tsx](/webview-ui/src/components/marketplace/MarketplaceView.tsx) + +### TagFilterGroup + +[/webview-ui/src/components/marketplace/MarketplaceView.tsx](/webview-ui/src/components/marketplace/MarketplaceView.tsx) + +## Styling Approach + +The Marketplace UI uses a combination of Tailwind CSS and VSCode theme variables for styling. + +## Responsive Design + +The Marketplace UI is designed to work across different viewport sizes: + +## Accessibility Features + +The Marketplace UI includes several accessibility features: + +### Keyboard Navigation + +```tsx +// Example of keyboard navigation support + +``` + +### Screen Reader Support + +```tsx +// Example of screen reader support +
+ + +
+``` + +### Focus Management + +```tsx +// Example of focus management +const buttonRef = useRef(null) + +useEffect(() => { + if (isOpen && buttonRef.current) { + buttonRef.current.focus() + } +}, [isOpen]) + +return ( + +) +``` + +### Color Contrast + +The UI ensures sufficient color contrast for all text: + +- Text uses VSCode theme variables that maintain proper contrast +- Interactive elements have clear focus states +- Color is not the only means of conveying information + +## Animation and Transitions + +The Marketplace UI uses subtle animations to enhance the user experience: + +### Expand/Collapse Animation + +```tsx +// Example of expand/collapse animation +
+ {children} +
+``` + +### Hover Effects + +```tsx +// Example of hover effects + +``` + +### Loading States + +```tsx +// Example of loading state animation +
+
+ Loading items... +
+``` + +## Error Handling in UI + +The Marketplace UI includes graceful error handling: + +### Error States + +```tsx +// Example of error state display +const ErrorDisplay: React.FC<{ error: string; retry: () => void }> = ({ error, retry }) => { + return ( +
+
+ +

Error loading items

+
+

{error}

+ +
+ ) +} +``` + +### Empty States + +```tsx +// Example of empty state display +const EmptyState: React.FC<{ message: string }> = ({ message }) => { + return ( +
+
+

{message}

+
+ ) +} +``` + +## Component Testing + +The Marketplace UI components include comprehensive tests: + +### Unit Tests + +```typescript +// Example of component unit test +describe("MarketplaceItemCard", () => { + const mockItem: MarketplaceItem = { + name: "Test Package", + description: "A test package", + type: "package", + url: "https://example.com", + repoUrl: "https://github.com/example/repo", + tags: ["test", "example"], + version: "1.0.0", + lastUpdated: "2025-04-01" + }; + + const mockFilters = { type: "", search: "", tags: [] }; + const mockSetFilters = jest.fn(); + const mockSetActiveTab = jest.fn(); + + it("renders correctly", () => { + render( + + ); + + expect(screen.getByText("Test Package")).toBeInTheDocument(); + expect(screen.getByText("A test package")).toBeInTheDocument(); + expect(screen.getByText("Package")).toBeInTheDocument(); + }); + + it("handles tag clicks", () => { + render( + + ); + + fireEvent.click(screen.getByText("test")); + + expect(mockSetFilters).toHaveBeenCalledWith({ + type: "", + search: "", + tags: ["test"] + }); + }); +}); +``` + +### Snapshot Tests + +```typescript +// Example of snapshot test +it("matches snapshot", () => { + const { container } = render( + + ); + + expect(container).toMatchSnapshot(); +}); +``` + +### Accessibility Tests + +```typescript +// Example of accessibility test +it("meets accessibility requirements", async () => { + const { container } = render( + + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); +``` + +--- + +**Previous**: [Search and Filter Implementation](./04-search-and-filter.md) | **Next**: [Testing Strategy](./06-testing-strategy.md) diff --git a/cline_docs/marketplace/implementation/06-testing-strategy.md b/cline_docs/marketplace/implementation/06-testing-strategy.md new file mode 100644 index 0000000000..3bb05a103b --- /dev/null +++ b/cline_docs/marketplace/implementation/06-testing-strategy.md @@ -0,0 +1,1170 @@ +# Testing Strategy + +This document outlines the comprehensive testing strategy for the Marketplace, including unit tests, integration tests, and test data management. + +## Testing Philosophy + +The Marketplace follows a multi-layered testing approach to ensure reliability and maintainability: + +1. **Unit Testing**: Testing individual components in isolation +2. **Integration Testing**: Testing interactions between components +3. **End-to-End Testing**: Testing complete user workflows +4. **Test-Driven Development**: Writing tests before implementation when appropriate +5. **Continuous Testing**: Running tests automatically on code changes + +## Test Setup and Dependencies + +### Required Dependencies + +The Marketplace requires specific testing dependencies: + +```json +{ + "devDependencies": { + "@types/jest": "^29.0.0", + "@types/mocha": "^10.0.0", + "@vscode/test-electron": "^2.3.8", + "jest": "^29.0.0", + "ts-jest": "^29.0.0" + } +} +``` + +### E2E Test Configuration + +End-to-end tests require specific setup: + +```typescript +// e2e/src/runTest.ts +import * as path from "path" +import { runTests } from "@vscode/test-electron" + +async function main() { + try { + const extensionDevelopmentPath = path.resolve(__dirname, "../../") + const extensionTestsPath = path.resolve(__dirname, "./suite/index") + + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: ["--disable-extensions"], + }) + } catch (err) { + console.error("Failed to run tests:", err) + process.exit(1) + } +} + +main() +``` + +### Test Framework Setup + +```typescript +// e2e/src/suite/index.ts +import * as path from "path" +import * as Mocha from "mocha" +import { glob } from "glob" + +export async function run(): Promise { + const mocha = new Mocha({ + ui: "tdd", + color: true, + timeout: 60000, + }) + + const testsRoot = path.resolve(__dirname, ".") + const files = await glob("**/**.test.js", { cwd: testsRoot }) + + files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))) + + try { + return new Promise((resolve, reject) => { + mocha.run((failures) => { + failures > 0 ? reject(new Error(`${failures} tests failed.`)) : resolve() + }) + }) + } catch (err) { + console.error(err) + throw err + } +} +``` + +### TypeScript Configuration + +E2E tests require specific TypeScript configuration: + +```json +// e2e/tsconfig.json +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "sourceMap": true, + "strict": true, + "types": ["mocha", "node", "@vscode/test-electron"] + }, + "exclude": ["node_modules", ".vscode-test"] +} +``` + +## Unit Tests + +Unit tests focus on testing individual functions, classes, and components in isolation. + +### Backend Unit Tests + +Backend unit tests verify the functionality of core services and utilities: + +#### MetadataScanner Tests + +```typescript +describe("MetadataScanner", () => { + let scanner: MetadataScanner + + beforeEach(() => { + scanner = new MetadataScanner() + }) + + describe("parseMetadataFile", () => { + it("should parse valid YAML metadata", async () => { + // Mock file system + jest.spyOn(fs, "readFile").mockImplementation((path, options, callback) => { + callback( + null, + Buffer.from(` + name: "Test Package" + description: "A test package" + version: "1.0.0" + type: "package" + `), + ) + }) + + const result = await scanner["parseMetadataFile"]("test/path/metadata.en.yml") + + expect(result).toEqual({ + name: "Test Package", + description: "A test package", + version: "1.0.0", + type: "package", + }) + }) + + it("should handle invalid YAML", async () => { + // Mock file system with invalid YAML + jest.spyOn(fs, "readFile").mockImplementation((path, options, callback) => { + callback( + null, + Buffer.from(` + name: "Invalid YAML + description: Missing quote + `), + ) + }) + + await expect(scanner["parseMetadataFile"]("test/path/metadata.en.yml")).rejects.toThrow() + }) + }) + + describe("scanDirectory", () => { + // Tests for directory scanning + }) +}) +``` + +#### MarketplaceManager Tests + +```typescript +describe("MarketplaceManager", () => { + let manager: MarketplaceManager + let mockContext: vscode.ExtensionContext + + beforeEach(() => { + // Create mock context + mockContext = { + extensionPath: "/test/path", + globalStorageUri: { fsPath: "/test/storage" }, + globalState: { + get: jest.fn().mockImplementation((key, defaultValue) => defaultValue), + update: jest.fn().mockResolvedValue(undefined), + }, + } as unknown as vscode.ExtensionContext + + manager = new MarketplaceManager(mockContext) + }) + + describe("filterItems", () => { + it("should filter by type", () => { + // Set up test data + manager["currentItems"] = [ + { name: "Item 1", type: "mode", description: "Test item 1" }, + { name: "Item 2", type: "package", description: "Test item 2" }, + ] as MarketplaceItem[] + + const result = manager.filterItems({ type: "mode" }) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe("Item 1") + }) + + it("should filter by search term", () => { + // Set up test data + manager["currentItems"] = [ + { name: "Alpha Item", type: "mode", description: "Test item" }, + { name: "Beta Item", type: "package", description: "Another test" }, + ] as MarketplaceItem[] + + const result = manager.filterItems({ search: "alpha" }) + + expect(result).toHaveLength(1) + expect(result[0].name).toBe("Alpha Item") + }) + + // More filter tests... + }) + + describe("addSource", () => { + // Tests for adding sources + }) +}) +``` + +#### Search Utilities Tests + +```typescript +describe("searchUtils", () => { + describe("containsSearchTerm", () => { + it("should return true for exact matches", () => { + expect(containsSearchTerm("hello world", "hello")).toBe(true) + }) + + it("should be case insensitive", () => { + expect(containsSearchTerm("Hello World", "hello")).toBe(true) + expect(containsSearchTerm("hello world", "WORLD")).toBe(true) + }) + + it("should handle undefined inputs", () => { + expect(containsSearchTerm(undefined, "test")).toBe(false) + expect(containsSearchTerm("test", "")).toBe(false) + }) + }) + + describe("itemMatchesSearch", () => { + it("should match on name", () => { + const item = { + name: "Test Item", + description: "Description", + } + + expect(itemMatchesSearch(item, "test")).toEqual({ + matched: true, + matchReason: { + nameMatch: true, + descriptionMatch: false, + }, + }) + }) + + // More search matching tests... + }) +}) +``` + +### Frontend Unit Tests + +Frontend unit tests verify the functionality of UI components: + +#### MarketplaceItemCard Tests + +```typescript +describe("MarketplaceItemCard", () => { + const mockItem: MarketplaceItem = { + name: "Test Package", + description: "A test package", + type: "package", + url: "https://example.com", + repoUrl: "https://github.com/example/repo", + tags: ["test", "example"], + version: "1.0.0", + lastUpdated: "2025-04-01" + }; + + const mockFilters = { type: "", search: "", tags: [] }; + const mockSetFilters = jest.fn(); + const mockSetActiveTab = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders correctly", () => { + render( + + ); + + expect(screen.getByText("Test Package")).toBeInTheDocument(); + expect(screen.getByText("A test package")).toBeInTheDocument(); + expect(screen.getByText("Package")).toBeInTheDocument(); + }); + + it("handles tag clicks", () => { + render( + + ); + + fireEvent.click(screen.getByText("test")); + + expect(mockSetFilters).toHaveBeenCalledWith({ + type: "", + search: "", + tags: ["test"] + }); + }); + + // More component tests... +}); +``` + +#### ExpandableSection Tests + +```typescript +describe("ExpandableSection", () => { + it("renders collapsed by default", () => { + render( + +
Test Content
+
+ ); + + expect(screen.getByText("Test Section")).toBeInTheDocument(); + expect(screen.queryByText("Test Content")).not.toBeVisible(); + }); + + it("expands when clicked", () => { + render( + +
Test Content
+
+ ); + + fireEvent.click(screen.getByText("Test Section")); + + expect(screen.getByText("Test Content")).toBeVisible(); + }); + + it("can be expanded by default", () => { + render( + +
Test Content
+
+ ); + + expect(screen.getByText("Test Content")).toBeVisible(); + }); + + // More component tests... +}); +``` + +#### TypeGroup Tests + +```typescript +describe("TypeGroup", () => { + const mockItems = [ + { name: "Item 1", description: "Description 1" }, + { name: "Item 2", description: "Description 2" } + ]; + + it("renders type heading and items", () => { + render(); + + expect(screen.getByText("Modes")).toBeInTheDocument(); + expect(screen.getByText("Item 1")).toBeInTheDocument(); + expect(screen.getByText("Item 2")).toBeInTheDocument(); + }); + + it("highlights items matching search term", () => { + render(); + + const item1 = screen.getByText("Item 1"); + const item2 = screen.getByText("Item 2"); + + expect(item1.className).toContain("text-vscode-textLink"); + expect(item2.className).not.toContain("text-vscode-textLink"); + expect(screen.getByText("match")).toBeInTheDocument(); + }); + + // More component tests... +}); +``` + +## Integration Tests + +Integration tests verify that different components work together correctly. + +### Backend Integration Tests + +```typescript +describe("Marketplace Integration", () => { + let manager: MarketplaceManager + let metadataScanner: MetadataScanner + let templateItems: MarketplaceItem[] + + beforeAll(async () => { + // Load real data from template + metadataScanner = new MetadataScanner() + const templatePath = path.resolve(__dirname, "marketplace-template") + templateItems = await metadataScanner.scanDirectory(templatePath, "https://example.com") + }) + + beforeEach(() => { + // Create a real context-like object + const context = { + extensionPath: path.resolve(__dirname, "../../../../"), + globalStorageUri: { fsPath: path.resolve(__dirname, "../../../../mock/settings/path") }, + } as vscode.ExtensionContext + + // Create real instances + manager = new MarketplaceManager(context) + + // Set up manager with template data + manager["currentItems"] = [...templateItems] + }) + + describe("Message Handler Integration", () => { + it("should handle search messages", async () => { + const message = { + type: "search", + search: "data platform", + typeFilter: "", + tagFilters: [], + } + + const result = await handleMarketplaceMessages(message, manager) + + expect(result.type).toBe("searchResults") + expect(result.data).toHaveLength(1) + expect(result.data[0].name).toContain("Data Platform") + }) + + it("should handle type filter messages", async () => { + const message = { + type: "search", + search: "", + typeFilter: "mode", + tagFilters: [], + } + + const result = await handleMarketplaceMessages(message, manager) + + expect(result.type).toBe("searchResults") + expect(result.data.every((item) => item.type === "mode")).toBe(true) + }) + + // More message handler tests... + }) + + describe("End-to-End Flow", () => { + it("should find items with matching subcomponents", async () => { + const message = { + type: "search", + search: "validator", + typeFilter: "", + tagFilters: [], + } + + const result = await handleMarketplaceMessages(message, manager) + + expect(result.data.length).toBeGreaterThan(0) + + // Check that subcomponents are marked as matches + const hasMatchingSubcomponent = result.data.some((item) => + item.items?.some((subItem) => subItem.matchInfo?.matched), + ) + expect(hasMatchingSubcomponent).toBe(true) + }) + + // More end-to-end flow tests... + }) +}) +``` + +### Frontend Integration Tests + +```typescript +describe("Marketplace UI Integration", () => { + const mockItems: MarketplaceItem[] = [ + { + name: "Test Package", + description: "A test package", + type: "package", + url: "https://example.com", + repoUrl: "https://github.com/example/repo", + tags: ["test", "example"], + items: [ + { + type: "mode", + path: "/test/path", + metadata: { + name: "Test Mode", + description: "A test mode", + type: "mode" + } + } + ] + }, + { + name: "Test Mode", + description: "Another test item", + type: "mode", + url: "https://example.com", + repoUrl: "https://github.com/example/repo", + tags: ["example"] + } + ]; + + beforeEach(() => { + // Mock VSCode API + (vscode.postMessage as jest.Mock).mockClear(); + }); + + it("should filter items when search is entered", async () => { + render(); + + // Both items should be visible initially + expect(screen.getByText("Test Package")).toBeInTheDocument(); + expect(screen.getByText("Test Mode")).toBeInTheDocument(); + + // Enter search term + const searchInput = screen.getByPlaceholderText("Search items..."); + fireEvent.change(searchInput, { target: { value: "another" } }); + + // Wait for debounce + await waitFor(() => { + expect(screen.queryByText("Test Package")).not.toBeInTheDocument(); + expect(screen.getByText("Test Mode")).toBeInTheDocument(); + }); + }); + + it("should expand details when search matches subcomponents", async () => { + render(); + + // Enter search term that matches a subcomponent + const searchInput = screen.getByPlaceholderText("Search items..."); + fireEvent.change(searchInput, { target: { value: "test mode" } }); + + // Wait for debounce and expansion + await waitFor(() => { + expect(screen.getByText("Test Mode")).toBeInTheDocument(); + expect(screen.getByText("A test mode")).toBeInTheDocument(); + }); + + // Check that the match is highlighted + const modeElement = screen.getByText("Test Mode"); + expect(modeElement.className).toContain("text-vscode-textLink"); + }); + + // More UI integration tests... +}); +``` + +## Test Data Management + +The Marketplace uses several approaches to manage test data: + +### Mock Data + +Mock data is used for simple unit tests: + +```typescript +const mockItems: MarketplaceItem[] = [ + { + name: "Test Package", + description: "A test package", + type: "package", + url: "https://example.com", + repoUrl: "https://github.com/example/repo", + tags: ["test", "example"], + version: "1.0.0", + }, + // More mock items... +] +``` + +### Test Fixtures + +Test fixtures provide more complex data structures: + +```typescript +// fixtures/metadata.ts +export const metadataFixtures = { + basic: { + name: "Basic Package", + description: "A basic package for testing", + version: "1.0.0", + type: "package", + }, + + withTags: { + name: "Tagged Package", + description: "A package with tags", + version: "1.0.0", + type: "package", + tags: ["test", "fixture", "example"], + }, + + withSubcomponents: { + name: "Complex Package", + description: "A package with subcomponents", + version: "1.0.0", + type: "package", + items: [ + { + type: "mode", + path: "/test/path/mode", + metadata: { + name: "Test Mode", + description: "A test mode", + type: "mode", + }, + }, + { + type: "mcp", + path: "/test/path/server", + metadata: { + name: "Test Server", + description: "A test server", + type: "mcp", + }, + }, + ], + }, +} +``` + +### Template Data + +Real template data is used for integration tests: + +```typescript +beforeAll(async () => { + // Load real data from template + metadataScanner = new MetadataScanner() + const templatePath = path.resolve(__dirname, "marketplace-template") + templateItems = await metadataScanner.scanDirectory(templatePath, "https://example.com") +}) +``` + +### Test Data Generators + +Generators create varied test data: + +```typescript +// Test data generator +function generatePackageItems(count: number): MarketplaceItem[] { + const types: MarketplaceItemType[] = ["mode", "mcp", "package", "prompt"] + const tags = ["test", "example", "data", "ui", "server", "client"] + + return Array.from({ length: count }, (_, i) => { + const type = types[i % types.length] + const randomTags = tags.filter(() => Math.random() > 0.5).slice(0, Math.floor(Math.random() * 4)) + + return { + name: `Test ${type} ${i + 1}`, + description: `This is a test ${type} for testing purposes`, + type, + url: `https://example.com/${type}/${i + 1}`, + repoUrl: "https://github.com/example/repo", + tags: randomTags.length ? randomTags : undefined, + version: "1.0.0", + lastUpdated: new Date().toISOString(), + items: type === "package" ? generateSubcomponents(Math.floor(Math.random() * 5) + 1) : undefined, + } + }) +} + +function generateSubcomponents(count: number): MarketplaceItem["items"] { + const types: MarketplaceItemType[] = ["mode", "mcp", "prompt"] + + return Array.from({ length: count }, (_, i) => { + const type = types[i % types.length] + + return { + type, + path: `/test/path/${type}/${i + 1}`, + metadata: { + name: `Test ${type} ${i + 1}`, + description: `This is a test ${type} subcomponent`, + type, + }, + } + }) +} +``` + +## Type Filter Test Plan + +This section outlines the test plan for the type filtering functionality in the Marketplace, particularly focusing on the improvements to make type filter behavior consistent with search term behavior. + +### Unit Tests + +#### 1. Basic Type Filtering Tests + +**Test: Filter by Package Type** + +- **Input**: Items with various types including "package" +- **Filter**: `{ type: "package" }` +- **Expected**: Only items with type "package" are returned +- **Verification**: Check that the returned items all have type "package" + +**Test: Filter by Mode Type** + +- **Input**: Items with various types including "mode" +- **Filter**: `{ type: "mode" }` +- **Expected**: Only items with type "mode" are returned +- **Verification**: Check that the returned items all have type "mode" + +**Test: Filter by mcp Type** + +- **Input**: Items with various types including "mcp" +- **Filter**: `{ type: "mcp" }` +- **Expected**: Only items with type "mcp" are returned +- **Verification**: Check that the returned items all have type "mcp" + +#### 2. Package with Subcomponents Tests + +**Test: Package with Matching Subcomponents** + +- **Input**: A package with subcomponents of various types +- **Filter**: `{ type: "mode" }` +- **Expected**: The package is returned if it contains at least one subcomponent with type "mode" +- **Verification**: + - Check that the package is returned + - Check that `item.matchInfo.matched` is `true` + - Check that `item.matchInfo.matchReason.hasMatchingSubcomponents` is `true` + - Check that subcomponents with type "mode" have `subItem.matchInfo.matched` set to `true` + - Check that subcomponents with other types have `subItem.matchInfo.matched` set to `false` + +**Test: Package with No Matching Subcomponents** + +- **Input**: A package with subcomponents of various types, but none matching the filter +- **Filter**: `{ type: "prompt" }` +- **Expected**: The package is not returned +- **Verification**: Check that the package is not in the returned items + +**Test: Package with No Subcomponents** + +- **Input**: A package with no subcomponents +- **Filter**: `{ type: "mode" }` +- **Expected**: The package is not returned (since it's not a mode and has no subcomponents) +- **Verification**: Check that the package is not in the returned items + +#### 3. Combined Filtering Tests + +**Test: Type Filter and Search Term** + +- **Input**: Various items including packages with subitems +- **Filter**: `{ type: "mode", search: "test" }` +- **Expected**: Only items that match both the type filter and the search term are returned +- **Verification**: + - Check that all returned items have type "mode" or are packages with mode subcomponents + - Check that all returned items have "test" in their name or description, or have subcomponents with "test" in their name or description + +**Test: Type Filter and Tags** + +- **Input**: Various items with different tags +- **Filter**: `{ type: "mode", tags: ["test"] }` +- **Expected**: Only items that match both the type filter and have the "test" tag are returned +- **Verification**: Check that all returned items have type "mode" or are packages with mode subcomponents, and have the "test" tag + +### Integration Tests + +#### 1. UI Display Tests + +**Test: Type Filter UI Updates** + +- **Action**: Apply a type filter in the UI +- **Expected**: + - The UI shows only items that match the filter + - For packages, subcomponents that match the filter are highlighted or marked in some way +- **Verification**: Visually inspect the UI to ensure it correctly displays which items and subcomponents match the filter + +**Test: Type Filter and Search Combination** + +- **Action**: Apply both a type filter and a search term in the UI +- **Expected**: The UI shows only items that match both the type filter and the search term +- **Verification**: Visually inspect the UI to ensure it correctly displays which items match both filters + +#### 2. Real Data Tests + +**Test: Filter with Real Package Data** + +- **Input**: Real package data from the default package source +- **Action**: Apply various type filters +- **Expected**: The results match the expected behavior for each filter +- **Verification**: Check that the results are consistent with the expected behavior + +### Regression Tests + +#### 1. Search Term Filtering + +**Test: Search Term Only** + +- **Input**: Various items including packages with subcomponents +- **Filter**: `{ search: "test" }` +- **Expected**: The behavior is unchanged from before the type filter improvements +- **Verification**: Compare the results with the expected behavior from the previous implementation + +#### 2. Tag Filtering + +**Test: Tag Filter Only** + +- **Input**: Various items with different tags +- **Filter**: `{ tags: ["test"] }` +- **Expected**: The behavior is unchanged from before the type filter improvements +- **Verification**: Compare the results with the expected behavior from the previous implementation + +#### 3. No Filters + +**Test: No Filters Applied** + +- **Input**: Various items +- **Filter**: `{}` +- **Expected**: All items are returned +- **Verification**: Check that all items are returned and that their `matchInfo` properties are set correctly + +### Edge Cases + +#### 1. Empty Input + +**Test: Empty Items Array** + +- **Input**: Empty array +- **Filter**: `{ type: "mode" }` +- **Expected**: Empty array is returned +- **Verification**: Check that an empty array is returned + +#### 2. Invalid Filters + +**Test: Invalid Type** + +- **Input**: Various items +- **Filter**: `{ type: "invalid" as MarketplaceItemType }` +- **Expected**: No items are returned (since none match the invalid type) +- **Verification**: Check that an empty array is returned + +#### 3. Null or Undefined Values + +**Test: Null Subcomponents** + +- **Input**: A package with `items: null` +- **Filter**: `{ type: "mode" }` +- **Expected**: The package is not returned (since it has no subcomponents to match) +- **Verification**: Check that the package is not in the returned items + +**Test: Undefined Metadata** + +- **Input**: A package with subcomponents that have `metadata: undefined` +- **Filter**: `{ type: "mode" }` +- **Expected**: The package is returned if any subcomponents have type "mode" +- **Verification**: Check that the package is returned if appropriate and that subcomponents with undefined metadata are handled correctly + +### Performance Tests + +#### 1. Large Dataset + +**Test: Filter Large Dataset** + +- **Input**: A large number of items (e.g., 1000+) +- **Filter**: Various filters +- **Expected**: The filtering completes in a reasonable time +- **Verification**: Measure the time taken to filter the items and ensure it's within acceptable limits + +#### 2. Deep Nesting + +**Test: Deeply Nested Items** + +- **Input**: Items with deeply nested subcomponents +- **Filter**: Various filters +- **Expected**: The filtering correctly handles the nested structure +- **Verification**: Check that the results are correct for deeply nested structures + +## Test Organization + +The Marketplace tests are organized by functionality rather than by file structure: + +### Consolidated Test Files + +``` +src/services/marketplace/__tests__/ +├── Marketplace.consolidated.test.ts # Combined tests +├── searchUtils.test.ts # Search utility tests +└── PackageSubcomponents.test.ts # Subcomponent tests +``` + +### Test Structure + +Tests are organized into logical groups: + +```typescript +describe("Marketplace", () => { + // Shared setup + + describe("Direct Filtering", () => { + // Tests for filtering functionality + }) + + describe("Message Handler Integration", () => { + // Tests for message handling + }) + + describe("Sorting", () => { + // Tests for sorting functionality + }) +}) +``` + +## Test Coverage + +The Marketplace maintains high test coverage: + +### Coverage Goals + +- **Backend Logic**: 90%+ coverage +- **UI Components**: 80%+ coverage +- **Integration Points**: 85%+ coverage + +### Coverage Reporting + +```typescript +// jest.config.js +module.exports = { + // ...other config + collectCoverage: true, + coverageReporters: ["text", "lcov", "html"], + coverageThreshold: { + global: { + branches: 80, + functions: 85, + lines: 85, + statements: 85, + }, + "src/services/marketplace/*.ts": { + branches: 90, + functions: 90, + lines: 90, + statements: 90, + }, + }, +} +``` + +### Critical Path Testing + +Critical paths have additional test coverage: + +1. **Search and Filter**: Comprehensive tests for all filter combinations +2. **Message Handling**: Tests for all message types and error conditions +3. **UI Interactions**: Tests for all user interaction flows + +## Test Performance + +The Marketplace tests are optimized for performance: + +### Fast Unit Tests + +```typescript +// Fast unit tests with minimal dependencies +describe("containsSearchTerm", () => { + it("should return true for exact matches", () => { + expect(containsSearchTerm("hello world", "hello")).toBe(true) + }) + + // More tests... +}) +``` + +### Optimized Integration Tests + +```typescript +// Optimized integration tests +describe("Marketplace Integration", () => { + // Load template data once for all tests + beforeAll(async () => { + templateItems = await metadataScanner.scanDirectory(templatePath) + }) + + // Create fresh manager for each test + beforeEach(() => { + manager = new MarketplaceManager(mockContext) + manager["currentItems"] = [...templateItems] + }) + + // Tests... +}) +``` + +### Parallel Test Execution + +```typescript +// jest.config.js +module.exports = { + // ...other config + maxWorkers: "50%", // Use 50% of available cores + maxConcurrency: 5, // Run up to 5 tests concurrently +} +``` + +## Continuous Integration + +The Marketplace tests are integrated into the CI/CD pipeline: + +### GitHub Actions Workflow + +```yaml +# .github/workflows/test.yml +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: "16" + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Upload coverage + uses: codecov/codecov-action@v2 + with: + file: ./coverage/lcov.info +``` + +### Pre-commit Hooks + +```json +// package.json +{ + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "*.{ts,tsx}": ["eslint --fix", "jest --findRelatedTests"] + } +} +``` + +## Test Debugging + +The Marketplace includes tools for debugging tests: + +### Debug Logging + +```typescript +// Debug logging in tests +describe("Complex integration test", () => { + it("should handle complex search", async () => { + // Enable debug logging for this test + const originalDebug = process.env.DEBUG + process.env.DEBUG = "marketplace:*" + + // Test logic... + + // Restore debug setting + process.env.DEBUG = originalDebug + }) +}) +``` + +### Visual Debugging + +```typescript +// Visual debugging for UI tests +describe("UI component test", () => { + it("should render correctly", async () => { + const { container } = render(); + + // Save screenshot for visual debugging + if (process.env.SAVE_SCREENSHOTS) { + const screenshot = await page.screenshot(); + fs.writeFileSync("./screenshots/item-card.png", screenshot); + } + + // Test assertions... + }); +}); +``` + +## Test Documentation + +The Marketplace tests include comprehensive documentation: + +### Test Comments + +```typescript +/** + * Tests the search functionality with various edge cases + * + * Edge cases covered: + * - Empty search term + * - Case sensitivity + * - Special characters + * - Very long search terms + * - Matching in subcomponents + */ +describe("Search functionality", () => { + // Tests... +}) +``` + +### Test Scenarios + +```typescript +describe("Package filtering", () => { + /** + * Scenario: User filters by type and search term + * Given: A list of items of different types + * When: The user selects a type filter and enters a search term + * Then: Only items of the selected type containing the search term should be shown + */ + it("should combine type and search filters", () => { + // Test implementation... + }) +}) +``` + +--- + +**Previous**: [UI Component Design](./05-ui-components.md) | **Next**: [Extending the Marketplace](./07-extending.md) diff --git a/cline_docs/marketplace/implementation/07-extending.md b/cline_docs/marketplace/implementation/07-extending.md new file mode 100644 index 0000000000..73e6e0e2c2 --- /dev/null +++ b/cline_docs/marketplace/implementation/07-extending.md @@ -0,0 +1,926 @@ +# Extending the Marketplace + +This document provides guidance on extending the Marketplace with new features, component types, and customizations. + +## Adding New Component Types + +The Marketplace is designed to be extensible, allowing for the addition of new component types beyond the default ones (mode, mcp, prompt, package). + +### Extending the MarketplaceItemType + +To add a new component type: + +1. **Update the MarketplaceItemType Type**: + +```typescript +/** + * Supported component types + */ +export type MarketplaceItemType = "mode" | "prompt" | "package" | "mcp" | "your-new-type" +``` + +2. **Update Type Label Functions**: + +```typescript +const getTypeLabel = (type: string) => { + switch (type) { + case "mode": + return "Mode" + case "mcp": + return "MCP Server" + case "prompt": + return "Prompt" + case "package": + return "Package" + case "your-new-type": + return "Your New Type" + default: + return "Other" + } +} +``` + +3. **Update Type Color Functions**: + +```typescript +const getTypeColor = (type: string) => { + switch (type) { + case "mode": + return "bg-blue-600" + case "mcp": + return "bg-green-600" + case "prompt": + return "bg-purple-600" + case "package": + return "bg-orange-600" + case "your-new-type": + return "bg-yellow-600" // Choose a distinctive color + default: + return "bg-gray-600" + } +} +``` + +4. **Update Type Group Labels**: + +```typescript +const getTypeGroupLabel = (type: string) => { + switch (type) { + case "mode": + return "Modes" + case "mcp": + return "MCP Servers" + case "prompt": + return "Prompts" + case "package": + return "Packages" + case "your-new-type": + return "Your New Types" + default: + return `${type.charAt(0).toUpperCase()}${type.slice(1)}s` + } +} +``` + +### Directory Structure for New Types + +When adding a new component type, follow this directory structure in your source repository: + +``` +repository-root/ +├── metadata.en.yml +├── your-new-type/ # Directory for your new component type +│ ├── component-1/ +│ │ └── metadata.en.yml +│ └── component-2/ +│ └── metadata.en.yml +└── ... +``` + +### Metadata for New Types + +The metadata for your new component type should follow the standard format: + +```yaml +name: "Your Component Name" +description: "Description of your component" +version: "1.0.0" +type: "your-new-type" +tags: + - relevant-tag-1 + - relevant-tag-2 +``` + +### UI Considerations for New Types + +When adding a new component type, consider these UI aspects: + +1. **Type Filtering**: + + - Add your new type to the type filter options + - Ensure proper labeling and styling + +2. **Type-Specific Rendering**: + + - Consider if your type needs special rendering in the UI + - Add any type-specific UI components or styles + +3. **Type Icons**: + - Choose an appropriate icon for your type + - Add it to the icon mapping + +```typescript +const getTypeIcon = (type: string) => { + switch (type) { + case "mode": + return "codicon-person" + case "mcp": + return "codicon-server" + case "prompt": + return "codicon-comment" + case "package": + return "codicon-package" + case "your-new-type": + return "codicon-your-icon" // Choose an appropriate icon + default: + return "codicon-symbol-misc" + } +} +``` + +## Creating Custom Templates + +You can create custom templates to provide a starting point for users creating new components. + +### Template Structure + +A custom template should follow this structure: + +``` +custom-template/ +├── metadata.en.yml +├── README.md +└── [component-specific files] +``` + +### Template Metadata + +The template metadata should include: + +```yaml +name: "Your Template Name" +description: "Description of your template" +version: "1.0.0" +type: "your-component-type" +template: true +templateFor: "your-component-type" +``` + +### Template Registration + +Register your template with the Marketplace: + +```typescript +// In your extension code +const registerTemplates = (context: vscode.ExtensionContext) => { + const templatePath = path.join(context.extensionPath, "templates", "your-template") + marketplace.registerTemplate(templatePath) +} +``` + +### Template Usage + +Users can create new components from your template: + +```typescript +// In the UI +const createFromTemplate = (templateName: string) => { + vscode.postMessage({ + type: "createFromTemplate", + templateName, + }) +} +``` + +## Implementing New Features + +The Marketplace is designed to be extended with new features. Here's how to implement common types of features: + +### Adding a New Filter Type + +To add a new filter type (beyond type, search, and tags): + +1. **Update the Filters Interface**: + +```typescript +interface Filters { + type: string + search: string + tags: string[] + yourNewFilter: string // Add your new filter +} +``` + +2. **Update the Filter Function**: + +```typescript +export function filterItems( + items: MarketplaceItem[], + filters: { + type?: string + search?: string + tags?: string[] + yourNewFilter?: string // Add your new filter + }, +): MarketplaceItem[] { + // Existing filter logic... + + // Add your new filter logic + if (filters.yourNewFilter) { + result = result.filter((item) => { + // Your filter implementation + return yourFilterLogic(item, filters.yourNewFilter) + }) + } + + return result +} +``` + +3. **Add UI Controls**: + +```tsx +const YourNewFilterControl: React.FC<{ + value: string + onChange: (value: string) => void +}> = ({ value, onChange }) => { + return ( +
+

Your New Filter

+ {/* Your filter UI controls */} +
+ ) +} +``` + +4. **Integrate with the Main UI**: + +```tsx + + + + + + +``` + +### Adding a New View Mode + +To add a new view mode (beyond the card view): + +1. **Add a View Mode State**: + +```typescript +type ViewMode = "card" | "list" | "yourNewView" + +const [viewMode, setViewMode] = useState("card") +``` + +2. **Create the View Component**: + +```tsx +const YourNewView: React.FC<{ + items: MarketplaceItem[] + filters: Filters + setFilters: (filters: Filters) => void +}> = ({ items, filters, setFilters }) => { + return
{/* Your view implementation */}
+} +``` + +3. **Add View Switching Controls**: + +```tsx +const ViewModeSelector: React.FC<{ + viewMode: ViewMode + setViewMode: (mode: ViewMode) => void +}> = ({ viewMode, setViewMode }) => { + return ( +
+ + + +
+ ) +} +``` + +4. **Integrate with the Main UI**: + +```tsx +
+
+ + {/* Other toolbar items */} +
+ +
+ {viewMode === "card" && } + {viewMode === "list" && } + {viewMode === "yourNewView" && } +
+
+``` + +### Adding Custom Actions + +To add custom actions for package items: + +1. **Create an Action Handler**: + +```typescript +const handleCustomAction = (item: MarketplaceItem) => { + vscode.postMessage({ + type: "customAction", + item: item.name, + itemType: item.type, + }) +} +``` + +2. **Add Action Button to the UI**: + +```tsx + +``` + +3. **Handle the Action in the Message Handler**: + +```typescript +case "customAction": + // Handle the custom action + const { item, itemType } = message; + // Your custom action implementation + return { + type: "customActionResult", + success: true, + data: { /* result data */ } + }; +``` + +## Customizing the UI + +The Marketplace UI can be customized in several ways: + +### Custom Styling + +To customize the styling: + +1. **Add Custom CSS Variables**: + +```css +/* In your CSS file */ +:root { + --package-card-bg: var(--vscode-panel-background); + --package-card-border: var(--vscode-panel-border); + --package-card-hover: var(--vscode-list-hoverBackground); + --your-custom-variable: #your-color; +} +``` + +2. **Use Custom Classes**: + +```tsx +
+
{/* Your custom UI */}
+
+``` + +3. **Add Custom Themes**: + +```typescript +type Theme = "default" | "compact" | "detailed" | "yourCustomTheme" + +const [theme, setTheme] = useState("default") + +// Theme-specific styles +const getThemeClasses = (theme: Theme) => { + switch (theme) { + case "compact": + return "compact-theme" + case "detailed": + return "detailed-theme" + case "yourCustomTheme": + return "your-custom-theme" + default: + return "default-theme" + } +} +``` + +### Custom Components + +To replace or extend existing components: + +1. **Create a Custom Component**: + +```tsx +const CustomPackageCard: React.FC = (props) => { + // Your custom implementation + return ( +
+ {/* Your custom UI */} +

{props.item.name}

+ {/* Additional custom elements */} +
{/* Custom footer content */}
+
+ ) +} +``` + +2. **Use Component Injection**: + +```tsx +interface ComponentOverrides { + PackageCard?: React.MarketplaceItemType + ExpandableSection?: React.MarketplaceItemType + TypeGroup?: React.MarketplaceItemType +} + +const MarketplaceView: React.FC<{ + initialItems: MarketplaceItem[] + componentOverrides?: ComponentOverrides +}> = ({ initialItems, componentOverrides = {} }) => { + // Component selection logic + const PackageCard = componentOverrides.PackageCard || MarketplaceItemCard + + return ( +
+ {items.map((item) => ( + + ))} +
+ ) +} +``` + +### Custom Layouts + +To implement custom layouts: + +1. **Create a Layout Component**: + +```tsx +const CustomLayout: React.FC<{ + sidebar: React.ReactNode + content: React.ReactNode + footer?: React.ReactNode +}> = ({ sidebar, content, footer }) => { + return ( +
+
{sidebar}
+
{content}
+ {footer &&
{footer}
} +
+ ) +} +``` + +2. **Use the Layout in the Main UI**: + +```tsx + + } + content={ +
+ {filteredItems.map((item) => ( + + ))} +
+ } + footer={
{`Showing ${filteredItems.length} of ${items.length} packages`}
} +/> +``` + +## Extending Backend Functionality + +The Marketplace backend can be extended with new functionality: + +### Custom Source Providers + +To add support for new source types: + +1. **Create a Source Provider Interface**: + +```typescript +interface SourceProvider { + type: string + canHandle(url: string): boolean + fetchItems(url: string): Promise +} +``` + +2. **Implement a Custom Provider**: + +```typescript +class CustomSourceProvider implements SourceProvider { + type = "custom" + + canHandle(url: string): boolean { + return url.startsWith("custom://") + } + + async fetchItems(url: string): Promise { + // Your custom implementation + // Fetch items from your custom source + return items + } +} +``` + +3. **Register the Provider**: + +```typescript +// In your extension code +const registerSourceProviders = (marketplace: MarketplaceManager) => { + marketplace.registerSourceProvider(new CustomSourceProvider()) +} +``` + +### Custom Metadata Processors + +To add support for custom metadata formats: + +1. **Create a Metadata Processor Interface**: + +```typescript +interface MetadataProcessor { + canProcess(filePath: string): boolean + process(filePath: string, content: string): Promise +} +``` + +2. **Implement a Custom Processor**: + +```typescript +class CustomMetadataProcessor implements MetadataProcessor { + canProcess(filePath: string): boolean { + return filePath.endsWith(".custom") + } + + async process(filePath: string, content: string): Promise { + // Your custom processing logic + return processedMetadata + } +} +``` + +3. **Register the Processor**: + +```typescript +// In your extension code +const registerMetadataProcessors = (metadataScanner: MetadataScanner) => { + metadataScanner.registerProcessor(new CustomMetadataProcessor()) +} +``` + +### Custom Message Handlers + +To add support for custom messages: + +1. **Extend the Message Handler**: + +```typescript +// In your extension code +const extendMessageHandler = () => { + const originalHandler = handleMarketplaceMessages + + return async (message: any, marketplace: MarketplaceManager) => { + // Handle custom messages + if (message.type === "yourCustomMessage") { + // Your custom message handling + return { + type: "yourCustomResponse", + data: { + /* response data */ + }, + } + } + + // Fall back to the original handler + return originalHandler(message, marketplace) + } +} +``` + +2. **Register the Extended Handler**: + +```typescript +// In your extension code +const customMessageHandler = extendMessageHandler() +context.subscriptions.push( + vscode.commands.registerCommand("marketplace.handleMessage", (message) => { + return customMessageHandler(message, marketplace) + }), +) +``` + +## Integration with Other Systems + +The Marketplace can be integrated with other systems: + +### Integration with External APIs + +To integrate with external APIs: + +1. **Create an API Client**: + +```typescript +class ExternalApiClient { + private baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + async fetchPackages(): Promise { + const response = await fetch(`${this.baseUrl}/packages`) + const data = await response.json() + + // Transform API data to MarketplaceItem format + return data.map((item) => ({ + name: item.name, + description: item.description, + type: item.type, + url: item.url, + repoUrl: item.repository_url, + // Map other fields + })) + } +} +``` + +2. **Create a Source Provider for the API**: + +```typescript +class ApiSourceProvider implements SourceProvider { + private apiClient: ExternalApiClient + + constructor(apiUrl: string) { + this.apiClient = new ExternalApiClient(apiUrl) + } + + type = "api" + + canHandle(url: string): boolean { + return url.startsWith("api://") + } + + async fetchItems(url: string): Promise { + return this.apiClient.fetchPackages() + } +} +``` + +3. **Register the API Provider**: + +```typescript +// In your extension code +const registerApiProvider = (marketplace: MarketplaceManager) => { + marketplace.registerSourceProvider(new ApiSourceProvider("https://your-api.example.com")) +} +``` + +### Integration with Authentication Systems + +To integrate with authentication systems: + +1. **Create an Authentication Provider**: + +```typescript +class AuthProvider { + private token: string | null = null + + async login(): Promise { + // Your authentication logic + this.token = "your-auth-token" + return true + } + + async getToken(): Promise { + if (!this.token) { + await this.login() + } + return this.token + } + + isAuthenticated(): boolean { + return !!this.token + } +} +``` + +2. **Use Authentication in API Requests**: + +```typescript +class AuthenticatedApiClient extends ExternalApiClient { + private authProvider: AuthProvider + + constructor(baseUrl: string, authProvider: AuthProvider) { + super(baseUrl) + this.authProvider = authProvider + } + + async fetchPackages(): Promise { + const token = await this.authProvider.getToken() + + if (!token) { + throw new Error("Authentication required") + } + + const response = await fetch(`${this.baseUrl}/packages`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + // Process response as before + } +} +``` + +### Integration with Local Development Tools + +To integrate with local development tools: + +1. **Create a Local Development Provider**: + +```typescript +class LocalDevProvider { + private workspacePath: string + + constructor(workspacePath: string) { + this.workspacePath = workspacePath + } + + async createLocalPackage(template: string, name: string): Promise { + const targetPath = path.join(this.workspacePath, name) + + // Create directory + await fs.promises.mkdir(targetPath, { recursive: true }) + + // Copy template files + // Your implementation + + return targetPath + } + + async buildLocalPackage(packagePath: string): Promise { + // Your build implementation + return true + } + + async testLocalPackage(packagePath: string): Promise { + // Your test implementation + return true + } +} +``` + +2. **Integrate with the Marketplace**: + +```typescript +// In your extension code +const registerLocalDevTools = (context: vscode.ExtensionContext) => { + const workspaceFolders = vscode.workspace.workspaceFolders + + if (!workspaceFolders) { + return + } + + const workspacePath = workspaceFolders[0].uri.fsPath + const localDevProvider = new LocalDevProvider(workspacePath) + + // Register commands + context.subscriptions.push( + vscode.commands.registerCommand("marketplace.createLocal", async (template, name) => { + return localDevProvider.createLocalPackage(template, name) + }), + + vscode.commands.registerCommand("marketplace.buildLocal", async (packagePath) => { + return localDevProvider.buildLocalPackage(packagePath) + }), + + vscode.commands.registerCommand("marketplace.testLocal", async (packagePath) => { + return localDevProvider.testLocalPackage(packagePath) + }), + ) +} +``` + +## Best Practices for Extensions + +When extending the Marketplace, follow these best practices: + +### Maintainable Code + +1. **Follow the Existing Patterns**: + + - Use similar naming conventions + - Follow the same code structure + - Maintain consistent error handling + +2. **Document Your Extensions**: + + - Add JSDoc comments to functions and classes + - Explain the purpose of your extensions + - Document any configuration options + +3. **Write Tests**: + - Add unit tests for new functionality + - Update integration tests as needed + - Ensure test coverage remains high + +### Performance Considerations + +1. **Lazy Loading**: + + - Load data only when needed + - Defer expensive operations + - Use pagination for large datasets + +2. **Efficient Data Processing**: + + - Minimize data transformations + - Use memoization for expensive calculations + - Batch operations when possible + +3. **UI Responsiveness**: + - Keep the UI responsive during operations + - Show loading indicators for async operations + - Use debouncing for frequent events + +### Compatibility + +1. **VSCode API Compatibility**: + + - Use stable VSCode API features + - Handle API version differences + - Test with multiple VSCode versions + +2. **Cross-Platform Support**: + + - Test on Windows, macOS, and Linux + - Use path.join for file paths + - Handle file system differences + +3. **Theme Compatibility**: + - Use VSCode theme variables + - Test with light and dark themes + - Support high contrast mode + +--- + +**Previous**: [Testing Strategy](./06-testing-strategy.md) diff --git a/cline_docs/marketplace/user-guide/01-introduction.md b/cline_docs/marketplace/user-guide/01-introduction.md new file mode 100644 index 0000000000..4fecb58d2e --- /dev/null +++ b/cline_docs/marketplace/user-guide/01-introduction.md @@ -0,0 +1,58 @@ +# Introduction to Marketplace + +## Overview and Purpose + +The Marketplace is a powerful feature in Roo Code that allows you to discover, browse, and utilize various items to enhance your development experience. It serves as a centralized hub for accessing: + +- **Modes**: Specialized AI assistants with different capabilities +- **MCP Servers**: Model Context Protocol servers that provide additional functionality +- **Prompts**: Pre-configured instructions for specific tasks +- **Packages**: Collections of related components + +The Marketplace simplifies the process of extending Roo Code's capabilities by providing a user-friendly interface to find, filter, and add new components to your environment. + +## Key Features and Capabilities + +### Component Discovery + +- Browse a curated collection of components +- View detailed information about each component +- Explore subcomponents within packages + +### Search and Filter + +- Search by name and description +- Filter by component type (mode, MCP server, etc.) +- Use tags to find related components +- Combine search and filters for precise results + +### Component Details + +- View comprehensive information about each component +- See version information +- Access source repositories directly +- Explore subcomponents organized by type + +### Item Management + +- Add new components to your environment +- Manage custom item sources +- Create and contribute your own packages + +## How to Access the Marketplace + +The Marketplace can be accessed through the Roo Code extension in VS Code: + +1. Open VS Code with the Roo Code extension installed +2. Click on the Roo Code icon in the activity bar +3. Select "Marketplace" from the available options + +Alternatively, you can use the Command Palette: + +1. Press `Ctrl+Shift+P` (Windows/Linux) or `Cmd+Shift+P` (Mac) to open the Command Palette +2. Type "Roo Code: Open Marketplace" +3. Press Enter to open the Marketplace + +--- + +**Next**: [Browsing items](./02-browsing-items.md) diff --git a/cline_docs/marketplace/user-guide/02-browsing-items.md b/cline_docs/marketplace/user-guide/02-browsing-items.md new file mode 100644 index 0000000000..5a98f51fff --- /dev/null +++ b/cline_docs/marketplace/user-guide/02-browsing-items.md @@ -0,0 +1,148 @@ +# Browsing + +## Understanding the Marketplace Interface + +The Marketplace interface is designed to provide a clean, intuitive experience for discovering and exploring available components. The main interface consists of several key areas: + +### Main Sections + +1. **Navigation Tabs** + + - **Browse**: View all available marketplace items + - **Sources**: Manage Marketplace sources + +2. **Filter Panel** + + - Type filters (Modes, MCP Servers, Packages, etc.) + - Search box + - Tag filters + +3. **Results Area** + - Marketplace items displaying component information + - Sorting options + +### Interface Layout + +``` +┌─────────────────────────────────────────────────────────┐ +│ [Browse] [Sources] │ +├─────────────────────────────────────────────────────────┤ +│ FILTERS │ +│ Types: [] Mode [] MCP Server [] Package [] Prompt │ +│ Search: [ ] │ +│ Tags: [Tag cloud] │ +├─────────────────────────────────────────────────────────┤ +│ MARKETPLACE Items │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Name [Type] │ │ +│ │ by Author │ │ +│ │ │ │ +│ │ Description text... │ │ +│ │ │ │ +│ │ [Tags] [Tags] [Tags] │ │ +│ │ │ │ +│ │ v1.0.0 Apr 12, 2025 [View] │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Another Item [Type] │ │ +│ │ ... │ │ +└─────────────────────────────────────────────────────────┘ +``` + +## Marketplace Item and Information Displayed + +Each item in the Marketplace is represented by a card that contains essential information about the component: + +### Card Elements + +1. **Header Section** + + - **Name**: The name of the component + - **Author**: The creator or maintainer of the component (if available) + - **Type Badge**: Visual indicator of the component type (Mode, MCP Server, etc.) + +2. **Description** + + - A brief overview of the component's purpose and functionality + +3. **Tags** + + - Clickable tags that categorize the component + - Can be used for filtering similar components + +4. **Metadata** + + - **Version**: The current version of the component (if available) + - **Last Updated**: When the component was last modified (if available) + +5. **Actions** + + - **View**: Button to access the component's source repository or documentation + +6. **Details Section** (expandable) + - Shows subcomponents grouped by type + - Displays additional information when expanded + +### Example Item + +``` +┌─────────────────────────────────────────────────────┐ +│ Data Platform Package [Package] │ +│ by Roo Team │ +│ │ +│ A comprehensive data processing and analysis │ +│ package with tools for ETL, visualization, and ML. │ +│ │ +│ [data] [analytics] [machine-learning] │ +│ │ +│ v2.1.0 Apr 10, 2025 [View] │ +│ │ +│ ▼ Component Details │ +│ MCP Servers: │ +│ 1. Data Validator - Validates data formats │ +│ 2. ML Predictor - Makes predictions on data │ +│ │ +│ Modes: │ +│ 1. Data Analyst - Helps with data analysis │ +│ 2. ETL Engineer - Assists with data pipelines │ +└─────────────────────────────────────────────────────┘ +``` + +## Navigating Between Items + +The Marketplace provides several ways to navigate through the available items: + +### Navigation Methods + +1. **Scrolling** + + - Scroll through the list of item cards to browse all available components + +2. **Filtering** + + - Use the filter panel to narrow down the displayed items + - Click on type filters to show only specific component types + - Enter search terms to find items by name or description + - Click on tags to filter by specific categories + +3. **Sorting** + + - Sort pacitemskages by name or last updated date + - Toggle between ascending and descending order + +4. **Tab Navigation** + - Switch between "Browse" and "Sources" tabs to manage Marketplace sources + +### Keyboard Navigation + +For accessibility and efficiency, the Marketplace supports keyboard navigation: + +- **Tab**: Move focus between interactive elements +- **Space/Enter**: Activate buttons or toggle filters +- **Arrow Keys**: Navigate between items +- **Escape**: Close expanded details or clear filters + +--- + +**Previous**: [Introduction to Marketplace](./01-introduction.md) | **Next**: [Searching and Filtering](./03-searching-and-filtering.md) diff --git a/cline_docs/marketplace/user-guide/03-searching-and-filtering.md b/cline_docs/marketplace/user-guide/03-searching-and-filtering.md new file mode 100644 index 0000000000..e63e92f2fc --- /dev/null +++ b/cline_docs/marketplace/user-guide/03-searching-and-filtering.md @@ -0,0 +1,140 @@ +# Searching and Filtering + +The Marketplace provides powerful search and filtering capabilities to help you quickly find the components you need. This guide explains how to effectively use these features to narrow down your search results. + +## Using the Search Functionality + +The search box allows you to find components by matching text in various fields: + +### What Gets Searched + +When you enter a search term, the Marketplace looks for matches in: + +1. **Item Name**: The primary identifier of the item +2. **Description**: The detailed explanation of the item's purpose +3. **Subcomponent Names and Descriptions**: Text within nested items + +### Search Features + +- **Case Insensitive**: Searches ignore letter case for easier matching +- **Whitespace Insensitive**: Extra spaces are normalized in the search +- **Partial Matching**: Finds results that contain your search term anywhere in the text +- **Instant Results**: Results update as you type +- **Match Highlighting**: Matching subcomponents are highlighted and expanded automatically + +### Search Implementation + +The search uses a simple string contains match that is case and whitespace insensitive. This means: + +- "Data" will match "data", "DATA", "Data", etc. +- "machine learning" will match "Machine Learning", "machine-learning", etc. +- Partial words will match: "valid" will match "validation", "validator", etc. + +### Search Tips + +- Use specific, distinctive terms to narrow results +- Try different variations if you don't find what you're looking for +- Search for technology names or specific functionality +- Look for highlighted "match" indicators in expanded details sections + +### Example Searches + +| Search Term | Will Find | +| ------------------ | --------------------------------------------------------------------------- | +| "data" | Items with "data" in their name, description, or subcomponents | +| "validator" | Items that include validation functionality or have validator subcomponents | +| "machine learning" | Items related to machine learning technology | + +## Filtering by Item Type + +The type filter allows you to focus on specific categories of items: + +### Available Type Filters + +- **Mode**: AI assistant personalities with specialized capabilities +- **MCP Server**: Model Context Protocol servers that provide additional functionality +- **Package**: Collections of related items +- **Prompt**: Pre-configured instructions for specific tasks + +### Using Type Filters + +1. Click on a type checkbox to show only items of that type +2. Select multiple types to show items that match any of the selected types +3. Clear all type filters to show all items again + +When filtering by type, packages are handled specially: + +- A package will be included if it matches the selected type +- A package will also be included if it contains any subcomponents matching the selected type +- When viewing a package that was included due to its subcomponents, the matching subcomponents will be highlighted + +### Type Filter Behavior + +- Type filters apply to both the primary item type and it's subcomponents +- Packages are included if they contain subcomponents matching the selected type +- The type is displayed as a badge on each item card +- Type filtering can be combined with search terms and tag filters + +## Using Tags for Filtering + +Tags provide a way to filter items by category, technology, or purpose: + +### Tag Functionality + +- Tags appear as clickable buttons on item cards +- Clicking a tag activates it as a filter +- Active tag filters are highlighted +- Items must have at least one of the selected tags to be displayed + +### Finding and Using Tags + +1. Browse through item cards to discover available tags +2. Click on a tag to filter for items with that tag +3. Click on additional tags to expand your filter (items with any of the selected tags will be shown) +4. Click on an active tag to deactivate it + +### Common Tags + +- Technology areas: "data", "web", "security", "ai" +- Programming languages: "python", "javascript", "typescript" +- Functionality: "testing", "documentation", "analysis" +- Domains: "finance", "healthcare", "education" + +## Combining Search and Filters + +For the most precise results, you can combine search terms, type filters, and tag filters: + +### How Combined Filtering Works + +1. **AND Logic Between Filter Types**: Items must match the search term AND the selected types AND have at least one of the selected tags +2. **OR Logic Within Tag Filters**: Items must have at least one of the selected tags + +### Combined Filter Examples + +| Search Term | Type Filter | Tag Filter | Will Find | +| --------------- | ----------- | ----------------------- | ---------------------------------------------------- | +| "data" | MCP Server | "analytics" | MCP Servers related to data analytics | +| "test" | Mode | "automation", "quality" | Test automation or quality-focused modes | +| "visualization" | Package | "dashboard", "chart" | Packages for creating dashboards or charts | +| "" | Mode | "" | All modes and packages containing mode subcomponents | + +### Clearing Filters + +To reset your search and start over: + +1. Clear the search box +2. Uncheck all type filters +3. Deactivate all tag filters by clicking on them + +### Filter Status Indicators + +The Marketplace provides visual feedback about your current filters: + +- Active type filters are checked +- Active tag filters are highlighted +- The search box shows your current search term +- Result counts may be displayed to show how many items match your filters + +--- + +**Previous**: [Browsing Items](./02-browsing-items.md) | **Next**: [Working with Package Details](./04-working-with-details.md) diff --git a/cline_docs/marketplace/user-guide/04-working-with-details.md b/cline_docs/marketplace/user-guide/04-working-with-details.md new file mode 100644 index 0000000000..ac831c52af --- /dev/null +++ b/cline_docs/marketplace/user-guide/04-working-with-details.md @@ -0,0 +1,143 @@ +# Working with Package Details + +Marketplace items often contain multiple items organized in a hierarchical structure; these items are referred to as "Packages" and must have a type of `package`. The items organized within a package are referred to as "subitems" and have all the same metadata properties of regular items. This guide explains how to work with the details section of package cards to explore and understand the elements within each package. + +## Expanding Package Details + +Most packages in the Marketplace contain subcomponents that are hidden by default to keep the interface clean. You can expand these details to see what's inside each package: + +### How to Expand Details + +1. Look for the "Component Details" section at the bottom of a package card +2. Click on the section header or the chevron icon (▶) to expand it +3. The section will animate open, revealing the components inside the package +4. Click again to collapse the section when you're done + +### Automatic Expansion + +The details section will expand automatically when: + +- Your search term matches text in a subcomponent +- This is the only condition for automatic expansion + +### Details Section Badge + +The details section may display a badge with additional information: + +- **Match count**: When your search term matches subcomponents, a badge shows how many matches were found (e.g., "3 matches") +- This helps you quickly identify which packages contain relevant subcomponents + +## Understanding Component Types + +Components within packages are grouped by their type to make them easier to find and understand: + +### Common Component Types + +1. **Modes** + + - AI assistant personalities with specialized capabilities + - Examples: Code Mode, Architect Mode, Debug Mode + +2. **MCP Servers** + + - Model Context Protocol servers that provide additional functionality + - Examples: File Analyzer, Data Validator, Image Generator + +3. **Prompts** + + - Pre-configured instructions for specific tasks + - Examples: Code Review, Documentation Generator, Test Case Creator + +4. **Packages** + - Nested collections of related components + - Can contain any of the other component types + +### Type Presentation + +Each type section in the details view includes: + +- A header with the type name (pluralized, e.g., "MCP Servers") +- A numbered list of components of that type +- Each component's name and description + +## Viewing Subcomponents + +The details section organizes subcomponents in a clear, structured format: + +### Subcomponent List Format + +``` +Component Details + Type Name: + 1. Component Name - Description text goes here + 2. Another Component - Its description + + Another Type: + 1. First Component - Description + 2. Second Component - Description +``` + +### Subcomponent Information + +Each subcomponent in the list displays: + +1. **Number**: Sequential number within its type group +2. **Name**: The name of the subcomponent +3. **Description**: A brief explanation of the subcomponent's purpose (if available) +4. **Match Indicator**: A "match" badge appears next to items that match your search term + +### Navigating Subcomponents + +- Scroll within the details section to see all subcomponents +- Components are grouped by type, making it easier to find specific functionality +- Long descriptions may be truncated with an ellipsis (...) to save space (limited to 100 characters) + +## Matching Search Terms in Subcomponents + +One of the most powerful features of the Marketplace is the ability to search within subcomponents: + +### How Subcomponent Matching Works + +1. Enter a search term in the search box +2. The Marketplace searches through all subcomponent names and descriptions +3. Packages with matching subcomponents remain visible in the results +4. The details section automatically expands for packages with matches +5. Matching subcomponents are highlighted and marked with a "match" badge + +### Visual Indicators for Matches + +When a subcomponent matches your search: + +- The component name is highlighted in a different color +- A "match" badge appears next to the component +- The details section automatically expands +- A badge on the details section header shows the number of matches + +### Search Implementation + +The search uses a simple string contains match that is case-insensitive: + +- "validator" will match "Data Validator", "Validator Tool", etc. +- "valid" will match "validation" or "validator" +- validator will not match "validation" +- The search will match any part of the name or description that contains the exact search term + +### Example Scenario + +If you search for "validator": + +1. Packages containing components with "validator" in their name or description remain visible +2. The details section expands automatically for packages with matching subcomponents +3. Components like "Data Validator" or those with "validator" in their description are highlighted +4. A badge might show "2 matches" if two subcomponents match your search term + +### Benefits of Subcomponent Matching + +- Find functionality buried deep within packages +- Discover relationships between components +- Identify packages that contain specific tools or capabilities +- Locate similar components across different packages + +--- + +**Previous**: [Searching and Filtering](./03-searching-and-filtering.md) | **Next**: [Adding Packages](./05-adding-packages.md) diff --git a/cline_docs/marketplace/user-guide/05-adding-packages.md b/cline_docs/marketplace/user-guide/05-adding-packages.md new file mode 100644 index 0000000000..55ebd13e37 --- /dev/null +++ b/cline_docs/marketplace/user-guide/05-adding-packages.md @@ -0,0 +1,226 @@ +## Item Structure, Metadata, and Features + +### Overview + +- Every component on the registry is an `item`. +- An `item` can be of type: `mcp`, `mode`, `prompt`, `package` +- Each item apart from `package` is a singular object, i.e: one mode, one mcp server. +- A `package` contains multiple other `item`s + - All internal sub-items of a `package` is contained in the binary on the `package` item metadata itself. +- Each `item` requires specific metadata files and follows a consistent directory structure. + +### Directory Structure + +The `registry` structure could be the root or placed in a `registry` directory of any `git` repository, a sample structure for a registry is: + +``` +registry/ +├── metadata.en.yml # Required metadata for the registry +│ +├── modes/ # `mode` items +│ └── a-mode-name/ +│ └── metadata.en.yml +├── mcps/ # `mcp` items +├── prompts/ # `prompt` items +│ +└── packages/ # `package` items + └── a-package-name/ + ├── metadata.en.yml # Required metadata + ├── metadata.fr.yml # Optional localized metadata (French) + ├── modes/ # `a-package-name`'s internal `mode` items + │ └── my-mode/ + │ └── metadata.en.yml + ├── mcps/ # `a-package-name`'s internal `mcp` items + │ └── my-server/ + │ └── metadata.en.yml + └── prompts/ # `a-package-name`'s internal `prompt` items + └── my-prompt/ + └── metadata.en.yml +``` + +### Metadata File Format + +Metadata files use YAML format and must include specific fields: + +#### `registry`: + +```yaml +name: "My Registry" +description: "A concise description for your registry" +version: "0.0.0" +author: "your name" # optional +authorUrl: "http://your.profile.url/" # optional +``` + +#### `item`: + +```yaml +name: "My Package" +description: "A concise description for your package" +version: "0.0.0" +type: "package" # One of: package, mode, mcp, prompt +sourceUrl: "https://url.to/source-repository" # Optional +binaryUrl: "https://url.to/binary.zip" +binaryHash: "SHA256-of-binary" +binarySource: "https://proof.of/source" # Optional, proof-of-source for the binary (tag/hash reference, build job, etc) +tags: + - tag1 + - tag2 +author: "your name" # optional +authorUrl: "http://your.profile.url/" # optional +``` + +### Localization Support + +You can provide metadata in multiple languages by using locale-specific files: + +**Important Notes on Localization:** + +- Only files with the pattern `metadata.{locale}.yml` are supported +- The Marketplace will display metadata in the user's locale if available +- If the user's locale is not available, it will fall back to English +- The English locale (`metadata.en.yml`) is required as a fallback +- Files without a locale code (e.g., just `metadata.yml`) are not supported + +### Configurable Support + +Powered with [**`Roo Rocket`**](https://github.com/NamesMT/roo-rocket), the registry supports configurable items like: + +- `mcp` with access token inputs. +- `mode` / `prompt` with feature flags. +- And further customizations that a creator can imagine. + - E.g: a `package` could prompt you for the location of its context folder. + +## Contributing Process + +To contribute your package to the official repository, follow these steps: + +### 1. Fork the Repository + +1. Visit the official Roo Code Packages repository: [https://github.com/RooVetGit/Roo-Code-Marketplace](https://github.com/RooVetGit/Roo-Code-Marketplace) +2. Click the "Fork" button in the top-right corner +3. This creates your own copy of the repository where you can make changes + +### 2. Clone Your Fork + +Clone your forked repository to your local machine: + +```bash +git clone https://github.com/YOUR-USERNAME/Roo-Code-Marketplace.git +cd Roo-Code-Marketplace +``` + +### 3. Create Your Item + +1. Create a new directory for your item with an appropriate name +2. Add the required metadata files (and subitem directories for `package`) +3. Follow the structure and format described above +4. Add `sourceUrl` that points to a repository or post with info/document for the item. + +Example of creating a simple package: + +```bash +mkdir -p my-package/modes/my-mode +touch my-package/metadata.en.yml +touch my-package/README.md +touch my-package/modes/my-mode/metadata.en.yml +``` + +### 4. Test Your Package + +Before submitting, test your package by adding your fork as a custom source in the Marketplace: + +1. In VS Code, open the Marketplace +2. Go to the "Settings" tab +3. Click "Add Source" +4. Enter your fork's URL (e.g., `https://github.com/YOUR-USERNAME/Roo-Code-Marketplace`) +5. Click "Add" +6. Verify that your package appears and functions correctly + +### 5. Commit and Push Your Changes + +Once you're satisfied with your package: + +```bash +git add . +git commit -m "Add my-package with mode component" +git push origin main +``` + +### 6. Create a Pull Request + +1. Go to the original repository: [https://github.com/RooVetGit/Roo-Code-Marketplace](https://github.com/RooVetGit/Roo-Code-Marketplace) +2. Click "Pull Requests" and then "New Pull Request" +3. Click "Compare across forks" +4. Select your fork as the head repository +5. Click "Create Pull Request" +6. Provide a clear title and description of your package +7. Submit the pull request + +### 7. Review Process + +After submitting your pull request: + +1. Maintainers will review your package +2. They may request changes or improvements +3. Once approved, your package will be merged into the main repository +4. Your package will be available to all users of the Marketplace + +## Best Practices + +- **Clear Documentation**: Include detailed documentation in your README.md +- **Descriptive Metadata**: Write clear, informative descriptions +- **Appropriate Tags**: Use relevant tags to make your package discoverable +- **Testing**: Thoroughly test your package before submitting +- **Localization**: Consider providing metadata in multiple languages +- **Semantic Versioning**: Follow semantic versioning for version numbers +- **Consistent Naming**: Use clear, descriptive names for components + +## Example package metadatas + +### Data Science Toolkit + +Here's an example of a data science package: + +**data-science-toolkit/metadata.en.yml**: + +```yaml +name: "Data Science Toolkit" +description: "A comprehensive collection of tools for data science workflows" +version: "1.0.0" +type: "package" +tags: + - data + - science + - analysis + - visualization + - machine learning +``` + +**data-science-toolkit/modes/data-scientist-mode/metadata.en.yml**: + +```yaml +name: "Data Scientist Mode" +description: "A specialized mode for data science tasks" +version: "1.0.0" +type: "mode" +tags: + - data + - science + - analysis +``` + +**data-science-toolkit/prompts/data-cleaning/metadata.en.yml**: + +```yaml +name: "Data Cleaning Prompt" +description: "A prompt for cleaning and preprocessing datasets" +version: "1.0.0" +type: "prompt" +tags: + - data + - cleaning + - preprocessing +``` + +**Previous**: [Working with Package Details](./04-working-with-details.md) | **Next**: [Adding Custom Sources](./06-adding-custom-sources.md) diff --git a/cline_docs/marketplace/user-guide/06-adding-custom-sources.md b/cline_docs/marketplace/user-guide/06-adding-custom-sources.md new file mode 100644 index 0000000000..7baf639631 --- /dev/null +++ b/cline_docs/marketplace/user-guide/06-adding-custom-sources.md @@ -0,0 +1,152 @@ +# Adding Custom Marketplace Sources + +The Marketplace allows you to extend its functionality by adding custom sources. This guide explains how to set up and manage your own Marktplace repositories to access additional components beyond the default offerings. + +## Setting up a Marketplace Source Repository + +A Marketplace source repository is a Git repository that contains Marketplace items organized in a specific structure. You can create your own repository to host custom packages: + +### Repository Requirements + +1. **Proper Structure**: The repository must follow the required directory structure +2. **Valid Metadata**: Each package must include properly formatted metadata files +3. **Git Repository**: The source must be a Git repository accessible via HTTPS + +### Building your registry repository + +#### Start from a sample registry repository + +Check the branches of the [**rm-samples**](https://github.com/NamesMT/rm-samples) repository here. + +#### Creating a New Repository + +1. Create a new repository on GitHub, GitLab, or another Git hosting service +2. Initialize the repository with a README.md file +3. Clone the repository to your local machine: + +```bash +git clone https://github.com/your-username/your-registry-repo.git +cd your-registry-repo +``` + +4. Create the basic registry structure: + +```bash +mkdir -p packages modes mcps prompts +touch metadata.en.yml +``` + +5. Add repository metadata to `metadata.en.yml`: + +```yaml +name: "Your Repository Name" +description: "A collection of custom packages for Roo Code" +version: "1.0.0" +``` + +6. Commit and push the initial structure: + +```bash +git add . +git commit -m "Initialize package repository structure" +git push origin main +``` + +## Adding Sources to Roo Code + +Once you have a properly structured source repository, you can add it to your Roo Code Marketplace as a source: + +### Default Package Source + +Roo Code comes with a default package source: + +- URL: `https://github.com/RooVetGit/Roo-Code-Marketplace` +- This source is enabled by default, and anytime all sources have been deleted. + +### Adding a New Source + +1. Open VS Code with the Roo Code extension +2. Navigate to the Marketplace +3. Switch to the "Sources" tab +4. Click the "Add Source" button +5. Enter the repository URL: + - Format: `https://github.com/username/repository.git` + - Example: `https://github.com/your-username/your-registry-repo.git` +6. Click "Add" to save the source + +### Managing Sources + +The "Sources" tab provides several options for managing your registry sources: + +1. **Remove**: Delete a source from your configuration +2. **Refresh**: Update the item list from a source - this is forced git clone/pull to override local caching of data + +### Source Caching and Refreshing + +Marketplace sources are cached to improve performance: + +- **Cache Duration**: Sources are cached for 1 hour (3600000 ms) +- **Force Refresh**: To force an immediate refresh of a source: + 1. Go to the "Sources" tab + 2. Click the "Refresh" button next to the source you want to update + 3. This will bypass the cache and fetch the latest data from the repository + +### Troubleshooting Sources + +If a source isn't loading properly: + +1. Check that the repository URL is correct +2. Ensure the repository follows the required structure +3. Look for error messages in the Marketplace interface +4. Try refreshing the sources list +5. Disable and re-enable the source + +## Creating Private Sources + +For team or organization use, you might want to create private sources: + +### Private Repository Setup + +1. Create a private repository on your Git hosting service +2. Follow the same structure requirements as public repositories +3. Set up appropriate access controls for your team members + +### Authentication Options + +To access private repositories, you may need to: + +1. Configure Git credentials on your system +2. Use a personal access token with appropriate permissions +3. Set up SSH keys for authentication + +### Organization Best Practices + +For teams and organizations: + +1. Designate maintainers responsible for the source +2. Establish quality standards for contributed items and packages +3. Create a review process for new additions +4. Document usage guidelines for team members +5. Consider implementing versioning for your items and packages + +## Using Multiple Sources + +The Marketplace supports multiple sources simultaneously: + +### Benefits of Multiple Sources + +- Access components from different providers +- Separate internal and external components +- Test new work before contributing them to the main repository +- Create specialized sources for different projects or teams + +### Source Management Strategy + +1. Keep the default source enabled for core components +2. Add specialized sources for specific needs +3. Create a personal source for testing and development +4. Refresh sources after you've pushed changes to them to get the latest items + +--- + +**Previous**: [Adding Packages](./05-adding-packages.md) | **Next**: [Marketplace Architecture](../implementation/01-architecture.md) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index fc18e96d54..853327e838 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -133,6 +133,11 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "action", action: "historyButtonClicked" }) }, + marketplaceButtonClicked: () => { + const visibleProvider = getVisibleProviderOrLog(outputChannel) + if (!visibleProvider) return + visibleProvider.postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) + }, showHumanRelayDialog: (params: { requestId: string; promptText: string }) => { const panel = getPanel() diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index 743f96c00e..f763ff0abb 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -8,6 +8,7 @@ import { arePathsEqual, getWorkspacePath } from "../../utils/path" import { logger } from "../../utils/logging" import { GlobalFileNames } from "../../shared/globalFileNames" import * as yaml from "yaml" +import { ensureSettingsDirectoryExists } from "../../utils/globalContext" const ROOMODES_FILENAME = ".roomodes" @@ -115,7 +116,7 @@ export class CustomModesManager { } public async getCustomModesFilePath(): Promise { - const settingsDir = await this.ensureSettingsDirectoryExists() + const settingsDir = await ensureSettingsDirectoryExists(this.context) const filePath = path.join(settingsDir, GlobalFileNames.customModes) const fileExists = await fileExistsAtPath(filePath) @@ -360,12 +361,6 @@ export class CustomModesManager { } } - private async ensureSettingsDirectoryExists(): Promise { - const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings") - await fs.mkdir(settingsDir, { recursive: true }) - return settingsDir - } - public async resetCustomModes(): Promise { try { const filePath = await this.getCustomModesFilePath() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 43f56b8fa5..c7b076cfcf 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import EventEmitter from "events" import { Anthropic } from "@anthropic-ai/sdk" +import { DEFAULT_MARKETPLACE_SOURCE } from "../../services/marketplace/constants" import delay from "delay" import axios from "axios" import pWaitFor from "p-wait-for" @@ -38,6 +39,7 @@ import { getTheme } from "../../integrations/theme/getTheme" import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" +import { MarketplaceManager } from "../../services/marketplace" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" import { CodeIndexManager } from "../../services/code-index/manager" import type { IndexProgressUpdate } from "../../services/code-index/interfaces/manager" @@ -82,6 +84,7 @@ export class ClineProvider extends EventEmitter implements return this._workspaceTracker } protected mcpHub?: McpHub // Change from private to protected + private marketplaceManager: MarketplaceManager public isViewLaunched = false public settingsImportedAt?: number @@ -128,6 +131,8 @@ export class ClineProvider extends EventEmitter implements .catch((error) => { this.log(`Failed to initialize MCP Hub: ${error}`) }) + + this.marketplaceManager = new MarketplaceManager(this.context) } // Adds a new Cline instance to clineStack, marking the start of a new task. @@ -232,6 +237,7 @@ export class ClineProvider extends EventEmitter implements this._workspaceTracker = undefined await this.mcpHub?.unregisterClient() this.mcpHub = undefined + this.marketplaceManager?.cleanup() this.customModesManager?.dispose() this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -728,7 +734,8 @@ export class ClineProvider extends EventEmitter implements * @param webview A reference to the extension webview */ private setWebviewMessageListener(webview: vscode.Webview) { - const onReceiveMessage = async (message: WebviewMessage) => webviewMessageHandler(this, message) + const onReceiveMessage = async (message: WebviewMessage) => + webviewMessageHandler(this, message, this.marketplaceManager) webview.onDidReceiveMessage(onReceiveMessage, null, this.disposables) } @@ -1259,6 +1266,7 @@ export class ClineProvider extends EventEmitter implements showRooIgnoredFiles, language, maxReadFileLine, + marketplaceSources, terminalCompressProgressBar, historyPreviewCollapsed, condensingApiConfigId, @@ -1272,12 +1280,18 @@ export class ClineProvider extends EventEmitter implements const allowedCommands = vscode.workspace.getConfiguration(Package.name).get("allowedCommands") || [] const cwd = this.cwd + const marketplaceItems = this.marketplaceManager.getCurrentItems() || [] + const marketplaceInstalledMetadata = this.marketplaceManager.IMM.fullMetadata + // Check if there's a system prompt override for the current mode const currentMode = mode ?? defaultModeSlug const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode) return { version: this.context.extension?.packageJSON?.version ?? "", + marketplaceItems, + marketplaceSources: marketplaceSources ?? [], + marketplaceInstalledMetadata, apiConfiguration, customInstructions, alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, @@ -1454,6 +1468,7 @@ export class ClineProvider extends EventEmitter implements telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true, maxReadFileLine: stateValues.maxReadFileLine ?? -1, + marketplaceSources: stateValues.marketplaceSources ?? [DEFAULT_MARKETPLACE_SOURCE], historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, // Explicitly add condensing settings condensingApiConfigId: stateValues.condensingApiConfigId, @@ -1560,6 +1575,14 @@ export class ClineProvider extends EventEmitter implements return this.mcpHub } + /** + * Set the marketplace manager instance + * @param marketplaceManager The marketplace manager instance + */ + public setMarketplaceManager(marketplaceManager: MarketplaceManager) { + this.marketplaceManager = marketplaceManager + } + /** * Returns properties to be included in every telemetry event * This method is called by the telemetry service to get context information diff --git a/src/core/webview/marketplaceMessageHandler.ts b/src/core/webview/marketplaceMessageHandler.ts new file mode 100644 index 0000000000..06afcebef7 --- /dev/null +++ b/src/core/webview/marketplaceMessageHandler.ts @@ -0,0 +1,321 @@ +import * as vscode from "vscode" +import { ClineProvider } from "./ClineProvider" +import { installMarketplaceItemWithParametersPayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" +import { + MarketplaceManager, + MarketplaceItemType, + MarketplaceSource, + validateSources, + ValidationError, +} from "../../services/marketplace" +import { DEFAULT_MARKETPLACE_SOURCE } from "../../services/marketplace/constants" +import { GlobalState } from "../../schemas" + +/** + * Handle marketplace-related messages from the webview + */ +export async function handleMarketplaceMessages( + provider: ClineProvider, + message: WebviewMessage, + marketplaceManager: MarketplaceManager, +): Promise { + // Utility function for updating global state + const updateGlobalState = async (key: K, value: GlobalState[K]) => + await provider.contextProxy.setValue(key, value) + + switch (message.type) { + case "openExternal": { + if (message.url) { + try { + vscode.env.openExternal(vscode.Uri.parse(message.url)) + } catch (error) { + console.error( + `Marketplace: Failed to open URL: ${error instanceof Error ? error.message : String(error)}`, + ) + vscode.window.showErrorMessage( + `Failed to open URL: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } else { + console.error("Marketplace: openExternal called without a URL") + } + return true + } + + case "marketplaceSources": { + if (message.sources) { + // Enforce maximum of 10 sources + const MAX_SOURCES = 10 + let updatedSources: MarketplaceSource[] + + if (message.sources.length > MAX_SOURCES) { + // Truncate to maximum allowed and show warning + updatedSources = message.sources.slice(0, MAX_SOURCES) + vscode.window.showWarningMessage( + `Maximum of ${MAX_SOURCES} marketplace sources allowed. Additional sources have been removed.`, + ) + } else { + updatedSources = message.sources + } + + // Validate sources using the validation utility + const validationErrors = validateSources(updatedSources) + + // Filter out invalid sources + if (validationErrors.length > 0) { + // Create a map of invalid indices + const invalidIndices = new Set() + validationErrors.forEach((error: ValidationError) => { + // Extract index from error message (Source #X: ...) + const match = error.message.match(/Source #(\d+):/) + if (match && match[1]) { + const index = parseInt(match[1], 10) - 1 // Convert to 0-based index + if (index >= 0 && index < updatedSources.length) { + invalidIndices.add(index) + } + } + }) + + // Filter out invalid sources + updatedSources = updatedSources.filter((_, index) => !invalidIndices.has(index)) + + // Show validation errors + const errorMessage = `Marketplace sources validation failed:\n${validationErrors.map((e: ValidationError) => e.message).join("\n")}` + console.error(errorMessage) + vscode.window.showErrorMessage(errorMessage) + } + + // Update the global state with the validated sources + await updateGlobalState("marketplaceSources", updatedSources) + + // Clean up cache directories for repositories that are no longer in the sources list + try { + await marketplaceManager.cleanupCacheDirectories(updatedSources) + } catch (error) { + console.error("Marketplace: Error during cache cleanup:", error) + } + + // Update the webview with the new state + await provider.postStateToWebview() + } + return true + } + + case "fetchMarketplaceItems": { + // Prevent multiple simultaneous fetches + if (marketplaceManager.isFetching) { + await provider.postMessageToWebview({ + type: "state", + text: "Fetch already in progress", + }) + marketplaceManager.isFetching = false + return true + } + + // Check if we need to force refresh using type assertion + // const forceRefresh = (message as any).forceRefresh === true + try { + marketplaceManager.isFetching = true + + try { + let sources = (provider.contextProxy.getValue("marketplaceSources") as MarketplaceSource[]) || [] + + if (!sources || sources.length === 0) { + sources = [DEFAULT_MARKETPLACE_SOURCE] + + // Save the default sources + await provider.contextProxy.setValue("marketplaceSources", sources) + } + + const enabledSources = sources.filter((s) => s.enabled) + + if (enabledSources.length === 0) { + vscode.window.showInformationMessage( + "No enabled sources configured. Add and enable sources to view items.", + ) + await provider.postStateToWebview() + return true + } + + const result = await marketplaceManager.getMarketplaceItems(enabledSources) + + // If there are errors but also items, show warning + if (result.errors && result.items.length > 0) { + vscode.window.showWarningMessage( + `Some marketplace sources failed to load:\n${result.errors.join("\n")}`, + ) + } + // If there are errors and no items, show error + else if (result.errors && result.items.length === 0) { + const errorMessage = `Failed to load marketplace sources:\n${result.errors.join("\n")}` + vscode.window.showErrorMessage(errorMessage) + await provider.postMessageToWebview({ + type: "state", + text: errorMessage, + }) + marketplaceManager.isFetching = false + } + + // The items are already stored in MarketplaceManager's currentItems + // No need to store in global state + + // Send state to webview + await provider.postStateToWebview() + + return true + } catch (initError) { + const errorMessage = `Marketplace initialization failed: ${initError instanceof Error ? initError.message : String(initError)}` + console.error("Error in marketplace initialization:", initError) + vscode.window.showErrorMessage(errorMessage) + await provider.postMessageToWebview({ + type: "state", + text: errorMessage, + }) + // The state will already be updated with empty items by MarketplaceManager + await provider.postStateToWebview() + marketplaceManager.isFetching = false + return false + } + } catch (error) { + const errorMessage = `Failed to fetch marketplace items: ${error instanceof Error ? error.message : String(error)}` + console.error("Failed to fetch marketplace items:", error) + vscode.window.showErrorMessage(errorMessage) + await provider.postMessageToWebview({ + type: "state", + text: errorMessage, + }) + marketplaceManager.isFetching = false + return false + } + } + + case "filterMarketplaceItems": { + if (message.filters) { + try { + // Update filtered items and post state + marketplaceManager.updateWithFilteredItems({ + type: message.filters.type as MarketplaceItemType | undefined, + search: message.filters.search, + tags: message.filters.tags, + }) + await provider.postStateToWebview() + } catch (error) { + console.error("Marketplace: Error filtering items:", error) + vscode.window.showErrorMessage("Failed to filter marketplace items") + } + } + return true + } + + case "refreshMarketplaceSource": { + if (message.url) { + try { + // Get the current sources + const sources = (provider.contextProxy.getValue("marketplaceSources") as MarketplaceSource[]) || [] + + // Find the source with the matching URL + const source = sources.find((s) => s.url === message.url) + + if (source) { + try { + // Refresh the repository with the source name + const refreshResult = await marketplaceManager.refreshRepository(message.url, source.name) + if (refreshResult.error) { + vscode.window.showErrorMessage( + `Failed to refresh source: ${source.name || message.url} - ${refreshResult.error}`, + ) + } else { + vscode.window.showInformationMessage( + `Successfully refreshed marketplace source: ${source.name || message.url}`, + ) + } + await provider.postStateToWebview() + } finally { + // Always notify the webview that the refresh is complete, even if it failed + await provider.postMessageToWebview({ + type: "repositoryRefreshComplete", + url: message.url, + }) + } + } else { + console.error(`Marketplace: Source URL not found: ${message.url}`) + vscode.window.showErrorMessage(`Source URL not found: ${message.url}`) + } + } catch (error) { + console.error( + `Marketplace: Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`, + ) + vscode.window.showErrorMessage( + `Failed to refresh source: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + return true + } + + case "installMarketplaceItem": { + if (message.mpItem) { + try { + await marketplaceManager + .installMarketplaceItem(message.mpItem, message.mpInstallOptions) + .then(async (r) => r === "$COMMIT" && (await provider.postStateToWebview())) + } catch (error) { + vscode.window.showErrorMessage( + `Failed to install item "${message.mpItem.name}":\n${error instanceof Error ? error.message : String(error)}`, + ) + } + } else { + console.error("Marketplace: installMarketplaceItem called without `mpItem`") + } + return true + } + case "installMarketplaceItemWithParameters": + if (message.payload) { + const result = installMarketplaceItemWithParametersPayloadSchema.safeParse(message.payload) + + if (result.success) { + const { item, parameters } = result.data + + try { + await marketplaceManager + .installMarketplaceItem(item, { parameters }) + .then(async (r) => r === "$COMMIT" && (await provider.postStateToWebview())) + } catch (error) { + console.error(`Error submitting marketplace parameters: ${error}`) + vscode.window.showErrorMessage( + `Failed to install item "${item.name}":\n${error instanceof Error ? error.message : String(error)}`, + ) + } + } else { + console.error("Invalid payload for installMarketplaceItemWithParameters message:", message.payload) + vscode.window.showErrorMessage( + 'Invalid "payload" received for installation: item or parameters missing.', + ) + } + } + return true + case "cancelMarketplaceInstall": { + vscode.window.showInformationMessage("Marketplace installation cancelled.") + return true + } + case "removeInstalledMarketplaceItem": { + if (message.mpItem) { + try { + await marketplaceManager + .removeInstalledMarketplaceItem(message.mpItem, message.mpInstallOptions) + .then(async (r) => r === "$COMMIT" && (await provider.postStateToWebview())) + } catch (error) { + vscode.window.showErrorMessage( + `Failed to remove item "${message.mpItem.name}":\n${error instanceof Error ? error.message : String(error)}`, + ) + } + } else { + console.error("Marketplace: removeInstalledMarketplaceItem called without `mpItem`") + } + return true + } + + default: + return false + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 09c472cc0d..2fcd90ed63 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -38,7 +38,26 @@ import { getCommand } from "../../utils/commands" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) -export const webviewMessageHandler = async (provider: ClineProvider, message: WebviewMessage) => { +import { MarketplaceManager } from "../../services/marketplace" +import { handleMarketplaceMessages } from "./marketplaceMessageHandler" + +const marketplaceMessages = new Set([ + "openExternal", + "marketplaceSources", + "fetchMarketplaceItems", + "filterMarketplaceItems", + "refreshMarketplaceSource", + "installMarketplaceItem", + "installMarketplaceItemWithParameters", + "cancelMarketplaceInstall", + "removeInstalledMarketplaceItem", +]) + +export const webviewMessageHandler = async ( + provider: ClineProvider, + message: WebviewMessage, + marketplaceManager?: MarketplaceManager, +) => { // Utility functions provided for concise get/update of global state via contextProxy API. const getGlobalState = (key: K) => provider.contextProxy.getValue(key) const updateGlobalState = async (key: K, value: GlobalState[K]) => @@ -1382,4 +1401,14 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We break } } + + if (marketplaceManager && marketplaceMessages.has(message.type)) { + try { + console.log(`DEBUG: Routing ${message.type} message to marketplaceMessageHandler`) + const result = await handleMarketplaceMessages(provider, message, marketplaceManager) + console.log(`DEBUG: Marketplace message handled successfully: ${message.type}, result: ${result}`) + } catch (error) { + console.error(`DEBUG: Error handling marketplace message: ${error}`) + } + } } diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index 904eba8530..242934eb90 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -202,6 +202,13 @@ type GlobalSettings = { } | undefined enhancementApiConfigId?: string | undefined + marketplaceSources?: + | { + url: string + name?: string | undefined + enabled: boolean + }[] + | undefined historyPreviewCollapsed?: boolean | undefined } @@ -978,6 +985,13 @@ type IpcMessage = } | undefined enhancementApiConfigId?: string | undefined + marketplaceSources?: + | { + url: string + name?: string | undefined + enabled: boolean + }[] + | undefined historyPreviewCollapsed?: boolean | undefined } text: string @@ -1490,6 +1504,13 @@ type TaskCommand = } | undefined enhancementApiConfigId?: string | undefined + marketplaceSources?: + | { + url: string + name?: string | undefined + enabled: boolean + }[] + | undefined historyPreviewCollapsed?: boolean | undefined } text: string diff --git a/src/exports/types.ts b/src/exports/types.ts index 6f4989df62..d0f128b8ba 100644 --- a/src/exports/types.ts +++ b/src/exports/types.ts @@ -202,6 +202,13 @@ type GlobalSettings = { } | undefined enhancementApiConfigId?: string | undefined + marketplaceSources?: + | { + url: string + name?: string | undefined + enabled: boolean + }[] + | undefined historyPreviewCollapsed?: boolean | undefined } @@ -992,6 +999,13 @@ type IpcMessage = } | undefined enhancementApiConfigId?: string | undefined + marketplaceSources?: + | { + url: string + name?: string | undefined + enabled: boolean + }[] + | undefined historyPreviewCollapsed?: boolean | undefined } text: string @@ -1506,6 +1520,13 @@ type TaskCommand = } | undefined enhancementApiConfigId?: string | undefined + marketplaceSources?: + | { + url: string + name?: string | undefined + enabled: boolean + }[] + | undefined historyPreviewCollapsed?: boolean | undefined } text: string diff --git a/src/i18n/locales/ca/marketplace.json b/src/i18n/locales/ca/marketplace.json new file mode 100644 index 0000000000..f71044825d --- /dev/null +++ b/src/i18n/locales/ca/marketplace.json @@ -0,0 +1,100 @@ +{ + "type-group": { + "modes": "Modes", + "mcps": "Servidors MCP", + "prompts": "Prompts", + "packages": "Paquets", + "generic-type": "{{type}}s", + "match": "coincidència" + }, + "item-card": { + "type-mode": "Mode", + "type-mcp": "Servidor MCP", + "type-prompt": "Prompt", + "type-package": "Paquet", + "type-other": "Altres", + "by-author": "per {{author}}", + "authors-profile": "Perfil de l'autor", + "remove-tag-filter": "Eliminar filtre d'etiqueta: {{tag}}", + "filter-by-tag": "Filtrar per etiqueta: {{tag}}", + "component-details": "Detalls del component", + "match-count": "{{count}} coincidènci{{count !== 1 ? 'es' : 'a'}}", + "view": "Veure", + "source": "Font" + }, + "install-sidebar": { + "title": "Instal·la {{itemName}}", + "installButton": "Instal·lar", + "cancelButton": "Cancel·lar" + }, + "filters": { + "search": { + "placeholder": "Cerca al mercat..." + }, + "type": { + "label": "Tipus", + "all": "Tots els tipus", + "mode": "Mode", + "mcp server": "Servidor MCP", + "prompt": "Prompt", + "package": "Paquet" + }, + "sort": { + "label": "Ordena per", + "name": "Nom", + "lastUpdated": "Última actualització" + }, + "tags": { + "label": "Etiquetes", + "clear_one": "Esborra {{count}} etiqueta seleccionada", + "clear_other": "Esborra {{count}} etiquetes seleccionades", + "placeholder": "Cerca etiquetes...", + "noResults": "No s'han trobat etiquetes.", + "selected_one": "{{count}} etiqueta seleccionada", + "selected_other": "{{count}} etiquetes seleccionades" + } + }, + "sources": { + "title": "Fonts del mercat", + "description": "Afegeix o gestiona fonts per als elements del mercat. Cada font és un repositori Git que conté definicions d'elements del mercat.", + "errors": { + "maxSources": "Màxim de {{max}} fonts permeses.", + "emptyUrl": "La URL no pot estar buida.", + "nonVisibleChars": "La URL conté caràcters no visibles.", + "invalidGitUrl": "Format d'URL de Git no vàlid.", + "duplicateUrl": "Ja existeix una font amb aquesta URL.", + "nameTooLong": "El nom no pot superar els 20 caràcters.", + "nonVisibleCharsName": "El nom conté caràcters no visibles.", + "duplicateName": "Ja existeix una font amb aquest nom." + }, + "add": { + "namePlaceholder": "Nom opcional de la font (p. ex. 'El meu repositori privat')", + "urlPlaceholder": "URL del repositori Git (p. ex. 'https://github.com/user/repo.git')", + "urlFormats": "Formats compatibles: HTTPS, SSH o ruta de fitxer local.", + "button": "Afegeix font" + }, + "current": { + "title": "Fonts actuals", + "empty": "Encara no s'han afegit fonts del mercat.", + "emptyHint": "Afegeix una font a dalt per explorar els elements del mercat.", + "refresh": "Actualitza la font", + "remove": "Elimina la font" + } + }, + "tabs": { + "browse": "Explora", + "sources": "Fonts" + }, + "title": "Mercat", + "items": { + "refresh": { + "refreshing": "Actualitzant elements del mercat..." + }, + "empty": { + "noItems": "No s'han trobat elements del mercat.", + "emptyHint": "Prova d'ajustar els filtres o els termes de cerca" + }, + "count_one": "{{count}} element", + "count_other": "{{count}} elements" + } +} diff --git a/src/i18n/locales/de/marketplace.json b/src/i18n/locales/de/marketplace.json new file mode 100644 index 0000000000..fb92a7ba95 --- /dev/null +++ b/src/i18n/locales/de/marketplace.json @@ -0,0 +1,100 @@ +{ + "type-group": { + "modes": "Modi", + "mcps": "MCP-Server", + "prompts": "Prompts", + "packages": "Pakete", + "generic-type": "{{type}}", + "match": "Treffer" + }, + "item-card": { + "type-mode": "Modus", + "type-mcp": "MCP-Server", + "type-prompt": "Prompt", + "type-package": "Paket", + "type-other": "Sonstiges", + "by-author": "von {{author}}", + "authors-profile": "Autorenprofil", + "remove-tag-filter": "Tag-Filter entfernen: {{tag}}", + "filter-by-tag": "Nach Tag filtern: {{tag}}", + "component-details": "Komponentendetails", + "match-count": "{{count}} Treffer", + "view": "Ansehen", + "source": "Quelle" + }, + "install-sidebar": { + "title": "Installiere {{itemName}}", + "installButton": "Installieren", + "cancelButton": "Abbrechen" + }, + "filters": { + "search": { + "placeholder": "Marktplatz durchsuchen..." + }, + "type": { + "label": "Typ", + "all": "Alle Typen", + "mode": "Modus", + "mcp server": "MCP-Server", + "prompt": "Prompt", + "package": "Paket" + }, + "sort": { + "label": "Sortieren nach", + "name": "Name", + "lastUpdated": "Zuletzt aktualisiert" + }, + "tags": { + "label": "Tags", + "clear_one": "{{count}} ausgewählten Tag löschen", + "clear_other": "{{count}} ausgewählte Tags löschen", + "placeholder": "Tags durchsuchen...", + "noResults": "Keine Tags gefunden.", + "selected_one": "{{count}} Tag ausgewählt", + "selected_other": "{{count}} Tags ausgewählt" + } + }, + "sources": { + "title": "Marktplatz-Quellen", + "description": "Füge Quellen für Marktplatz-Items hinzu oder verwalte sie. Jede Quelle ist ein Git-Repository, das Marktplatz-Item-Definitionen enthält.", + "errors": { + "maxSources": "Maximal {{max}} Quellen erlaubt.", + "emptyUrl": "URL darf nicht leer sein.", + "nonVisibleChars": "URL enthält nicht sichtbare Zeichen.", + "invalidGitUrl": "Ungültiges Git-URL-Format.", + "duplicateUrl": "Quelle mit dieser URL existiert bereits.", + "nameTooLong": "Name darf 20 Zeichen nicht überschreiten.", + "nonVisibleCharsName": "Name enthält nicht sichtbare Zeichen.", + "duplicateName": "Quelle mit diesem Namen existiert bereits." + }, + "add": { + "namePlaceholder": "Optionaler Quellname (z.B. 'Mein privates Repo')", + "urlPlaceholder": "Git-Repository-URL (z.B. 'https://github.com/user/repo.git')", + "urlFormats": "Unterstützte Formate: HTTPS, SSH oder lokaler Dateipfad.", + "button": "Quelle hinzufügen" + }, + "current": { + "title": "Aktuelle Quellen", + "empty": "Noch keine Marktplatz-Quellen hinzugefügt.", + "emptyHint": "Füge oben eine Quelle hinzu, um Marktplatz-Items zu durchsuchen.", + "refresh": "Quelle aktualisieren", + "remove": "Quelle entfernen" + } + }, + "tabs": { + "browse": "Durchsuchen", + "sources": "Quellen" + }, + "title": "Marktplatz", + "items": { + "refresh": { + "refreshing": "Marktplatz-Items werden aktualisiert..." + }, + "empty": { + "noItems": "Keine Marktplatz-Items gefunden.", + "emptyHint": "Versuche, deine Filter oder Suchbegriffe anzupassen" + }, + "count_one": "{{count}} Item", + "count_other": "{{count}} Items" + } +} diff --git a/src/i18n/locales/en/marketplace.json b/src/i18n/locales/en/marketplace.json new file mode 100644 index 0000000000..c58b121f5b --- /dev/null +++ b/src/i18n/locales/en/marketplace.json @@ -0,0 +1,104 @@ +{ + "type-group": { + "modes": "Modes", + "mcps": "MCP Servers", + "prompts": "Prompts", + "packages": "Packages", + "generic-type": "{{type}}s", + "match": "match" + }, + "item-card": { + "type-mode": "Mode", + "type-mcp": "MCP Server", + "type-prompt": "Prompt", + "type-package": "Package", + "type-other": "Other", + "by-author": "by {{author}}", + "authors-profile": "Author's Profile", + "remove-tag-filter": "Remove tag filter: {{tag}}", + "filter-by-tag": "Filter by tag: {{tag}}", + "component-details": "Component Details", + "match-count": "{{count}} match{{count !== 1 ? 'es' : ''}}", + "view": "View", + "source": "Source" + }, + "install-sidebar": { + "title": "Install {{itemName}}", + "installButton": "Install", + "cancelButton": "Cancel" + }, + "install-sidebar": { + "title": "Install {{itemName}}", + "installButton": "Install", + "cancelButton": "Cancel" + }, + "filters": { + "search": { + "placeholder": "Search marketplace..." + }, + "type": { + "label": "Type", + "all": "All Types", + "mode": "Mode", + "mcp server": "MCP Server", + "prompt": "Prompt", + "package": "Package" + }, + "sort": { + "label": "Sort By", + "name": "Name", + "lastUpdated": "Last Updated" + }, + "tags": { + "label": "Tags", + "clear": "Clear {{count}} selected tag{{count !== 1 ? 's' : ''}}", + "placeholder": "Search tags...", + "noResults": "No tags found.", + "selected": "{{count}} tag{{count !== 1 ? 's' : ''}} selected" + }, + "sources": { + "title": "Marketplace Sources", + "description": "Add or manage sources for marketplace items. Each source is a Git repository containing marketplace item definitions.", + "errors": { + "maxSources": "Maximum of {{max}} sources allowed.", + "emptyUrl": "URL cannot be empty.", + "nonVisibleChars": "URL contains non-visible characters.", + "invalidGitUrl": "Invalid Git URL format.", + "duplicateUrl": "Source with this URL already exists.", + "nameTooLong": "Name cannot exceed 20 characters.", + "nonVisibleCharsName": "Name contains non-visible characters.", + "duplicateName": "Source with this name already exists." + }, + "add": { + "namePlaceholder": "Optional source name (e.g. 'My Private Repo')", + "urlPlaceholder": "Git repository URL (e.g. 'https://github.com/user/repo.git')", + "urlFormats": "Supported formats: HTTPS, SSH, or local file path.", + "button": "Add Source" + }, + "current": { + "title": "Current Sources", + "empty": "No marketplace sources added yet.", + "emptyHint": "Add a source above to browse marketplace items." + }, + "current": { + "refresh": "Refresh source", + "remove": "Remove source" + } + }, + "tabs": { + "browse": "Browse", + "sources": "Sources" + }, + "title": "Marketplace" + }, + "items": { + "refresh": { + "refreshing": "Refreshing marketplace items..." + }, + "empty": { + "noItems": "No marketplace items found.", + "emptyHint": "Try adjusting your filters or search terms" + }, + "count": "{{count}} item{{count !== 1 ? 's' : ''}}" + } +} diff --git a/src/i18n/locales/es/marketplace.json b/src/i18n/locales/es/marketplace.json new file mode 100644 index 0000000000..4118594051 --- /dev/null +++ b/src/i18n/locales/es/marketplace.json @@ -0,0 +1,100 @@ +{ + "type-group": { + "modes": "Modos", + "mcps": "Servidores MCP", + "prompts": "Prompts", + "packages": "Paquetes", + "generic-type": "{{type}}s", + "match": "coincide" + }, + "item-card": { + "type-mode": "Modo", + "type-mcp": "Servidor MCP", + "type-prompt": "Prompt", + "type-package": "Paquete", + "type-other": "Otro", + "by-author": "por {{author}}", + "authors-profile": "Perfil del Autor", + "remove-tag-filter": "Eliminar filtro de etiqueta: {{tag}}", + "filter-by-tag": "Filtrar por etiqueta: {{tag}}", + "component-details": "Detalles del Componente", + "match-count": "{{count}} coincidencia{{count !== 1 ? 's' : ''}}", + "view": "Ver", + "source": "Fuente" + }, + "install-sidebar": { + "title": "Instalar {{itemName}}", + "installButton": "Instalar", + "cancelButton": "Cancelar" + }, + "filters": { + "search": { + "placeholder": "Buscar en el mercado..." + }, + "type": { + "label": "Tipo", + "all": "Todos los tipos", + "mode": "Modo", + "mcp server": "Servidor MCP", + "prompt": "Prompt", + "package": "Paquete" + }, + "sort": { + "label": "Ordenar por", + "name": "Nombre", + "lastUpdated": "Última actualización" + }, + "tags": { + "label": "Etiquetas", + "clear_one": "Borrar {{count}} etiqueta seleccionada", + "clear_other": "Borrar {{count}} etiquetas seleccionadas", + "placeholder": "Buscar etiquetas...", + "noResults": "No se encontraron etiquetas.", + "selected_one": "{{count}} etiqueta seleccionada", + "selected_other": "{{count}} etiquetas seleccionadas" + } + }, + "sources": { + "title": "Fuentes del mercado", + "description": "Añade o gestiona fuentes para los elementos del mercado. Cada fuente es un repositorio Git que contiene definiciones de elementos del mercado.", + "errors": { + "maxSources": "Máximo de {{max}} fuentes permitidas.", + "emptyUrl": "La URL no puede estar vacía.", + "nonVisibleChars": "La URL contiene caracteres no visibles.", + "invalidGitUrl": "Formato de URL de Git no válido.", + "duplicateUrl": "Ya existe una fuente con esta URL.", + "nameTooLong": "El nombre no puede superar los 20 caracteres.", + "nonVisibleCharsName": "El nombre contiene caracteres no visibles.", + "duplicateName": "Ya existe una fuente con este nombre." + }, + "add": { + "namePlaceholder": "Nombre opcional de la fuente (p. ej. 'Mi repositorio privado')", + "urlPlaceholder": "URL del repositorio Git (p. ej. 'https://github.com/user/repo.git')", + "urlFormats": "Formatos compatibles: HTTPS, SSH o ruta de archivo local.", + "button": "Añadir fuente" + }, + "current": { + "title": "Fuentes actuales", + "empty": "Aún no se han añadido fuentes del mercado.", + "emptyHint": "Añade una fuente arriba para explorar los elementos del mercado.", + "refresh": "Actualizar fuente", + "remove": "Eliminar fuente" + } + }, + "tabs": { + "browse": "Explorar", + "sources": "Fuentes" + }, + "title": "Mercado", + "items": { + "refresh": { + "refreshing": "Actualizando elementos del mercado..." + }, + "empty": { + "noItems": "No se encontraron elementos del mercado.", + "emptyHint": "Intenta ajustar tus filtros o términos de búsqueda" + }, + "count_one": "{{count}} elemento", + "count_other": "{{count}} elementos" + } +} diff --git a/src/i18n/locales/fr/marketplace.json b/src/i18n/locales/fr/marketplace.json new file mode 100644 index 0000000000..b54604f440 --- /dev/null +++ b/src/i18n/locales/fr/marketplace.json @@ -0,0 +1,100 @@ +{ + "type-group": { + "modes": "Modes", + "mcps": "Serveurs MCP", + "prompts": "Prompts", + "packages": "Paquets", + "generic-type": "{{type}}s", + "match": "correspondance" + }, + "item-card": { + "type-mode": "Mode", + "type-mcp": "Serveur MCP", + "type-prompt": "Prompt", + "type-package": "Paquet", + "type-other": "Autre", + "by-author": "par {{author}}", + "authors-profile": "Profil de l'auteur", + "remove-tag-filter": "Supprimer le filtre de tag : {{tag}}", + "filter-by-tag": "Filtrer par tag : {{tag}}", + "component-details": "Détails du composant", + "match-count": "{{count}} correspondance{{count !== 1 ? 's' : ''}}", + "view": "Voir", + "source": "Source" + }, + "install-sidebar": { + "title": "Installer {{itemName}}", + "installButton": "Installer", + "cancelButton": "Annuler" + }, + "filters": { + "search": { + "placeholder": "Rechercher sur la place de marché..." + }, + "type": { + "label": "Type", + "all": "Tous les types", + "mode": "Mode", + "mcp server": "Serveur MCP", + "prompt": "Prompt", + "package": "Paquet" + }, + "sort": { + "label": "Trier par", + "name": "Nom", + "lastUpdated": "Dernière mise à jour" + }, + "tags": { + "label": "Tags", + "clear_one": "Effacer {{count}} tag sélectionné", + "clear_other": "Effacer {{count}} tags sélectionnés", + "placeholder": "Rechercher des tags...", + "noResults": "Aucun tag trouvé.", + "selected_one": "{{count}} tag sélectionné", + "selected_other": "{{count}} tags sélectionnés" + } + }, + "sources": { + "title": "Sources de la place de marché", + "description": "Ajoutez ou gérez les sources des éléments de la place de marché. Chaque source est un dépôt Git contenant des définitions d'éléments de la place de marché.", + "errors": { + "maxSources": "Maximum de {{max}} sources autorisées.", + "emptyUrl": "L'URL ne peut pas être vide.", + "nonVisibleChars": "L'URL contient des caractères non visibles.", + "invalidGitUrl": "Format d'URL Git invalide.", + "duplicateUrl": "Une source avec cette URL existe déjà.", + "nameTooLong": "Le nom ne peut pas dépasser 20 caractères.", + "nonVisibleCharsName": "Le nom contient des caractères non visibles.", + "duplicateName": "Une source avec ce nom existe déjà." + }, + "add": { + "namePlaceholder": "Nom de source facultatif (par exemple, 'Mon dépôt privé')", + "urlPlaceholder": "URL du dépôt Git (par exemple, 'https://github.com/user/repo.git')", + "urlFormats": "Formats pris en charge : HTTPS, SSH ou chemin de fichier local.", + "button": "Ajouter une source" + }, + "current": { + "title": "Sources actuelles", + "empty": "Aucune source de place de marché ajoutée pour l'instant.", + "emptyHint": "Ajoutez une source ci-dessus pour parcourir les éléments de la place de marché.", + "refresh": "Actualiser la source", + "remove": "Supprimer la source" + } + }, + "tabs": { + "browse": "Parcourir", + "sources": "Sources" + }, + "title": "Place de marché", + "items": { + "refresh": { + "refreshing": "Actualisation des éléments de la place de marché..." + }, + "empty": { + "noItems": "Aucun élément de place de marché trouvé.", + "emptyHint": "Essayez d'ajuster vos filtres ou termes de recherche" + }, + "count_one": "{{count}} élément", + "count_other": "{{count}} éléments" + } +} diff --git a/src/i18n/locales/hi/marketplace.json b/src/i18n/locales/hi/marketplace.json new file mode 100644 index 0000000000..b4d1ea35cd --- /dev/null +++ b/src/i18n/locales/hi/marketplace.json @@ -0,0 +1,97 @@ +{ + "type-group": { + "modes": "मोड", + "mcps": "एमसीपी सर्वर", + "prompts": "प्रॉम्प्ट्स", + "packages": "पैकेज", + "generic-type": "{{type}}", + "match": "मिलान" + }, + "item-card": { + "type-mode": "मोड", + "type-mcp": "एमसीपी सर्वर", + "type-prompt": "प्रॉम्प्ट", + "type-package": "पैकेज", + "type-other": "अन्य", + "by-author": "लेखक: {{author}}", + "authors-profile": "लेखक का प्रोफ़ाइल", + "remove-tag-filter": "टैग फ़िल्टर हटाएं: {{tag}}", + "filter-by-tag": "टैग से फ़िल्टर करें: {{tag}}", + "component-details": "कंपोनेंट विवरण", + "match-count": "{{count}} मिलान", + "view": "देखें", + "source": "स्रोत" + }, + "install-sidebar": { + "title": "{{itemName}} इंस्टॉल करें", + "installButton": "इंस्टॉल करें", + "cancelButton": "रद्द करें" + }, + "filters": { + "search": { + "placeholder": "मार्केटप्लेस खोजें..." + }, + "type": { + "label": "प्रकार", + "all": "सभी प्रकार", + "mode": "मोड", + "mcp server": "एमसीपी सर्वर", + "prompt": "प्रॉम्प्ट", + "package": "पैकेज" + }, + "sort": { + "label": "इसके अनुसार क्रमबद्ध करें", + "name": "नाम", + "lastUpdated": "अंतिम अपडेट" + }, + "tags": { + "label": "टैग", + "clear": "{{count}} चयनित टैग साफ़ करें", + "placeholder": "टैग खोजें...", + "noResults": "कोई टैग नहीं मिला।", + "selected": "{{count}} टैग चयनित" + } + }, + "sources": { + "title": "मार्केटप्लेस स्रोत", + "description": "मार्केटप्लेस आइटम के लिए स्रोत जोड़ें या प्रबंधित करें। प्रत्येक स्रोत एक Git रिपॉजिटरी है जिसमें मार्केटप्लेस आइटम परिभाषाएँ होती हैं।", + "errors": { + "maxSources": "{{max}} स्रोतों की अधिकतम संख्या अनुमत है।", + "emptyUrl": "URL खाली नहीं हो सकती।", + "nonVisibleChars": "URL में गैर-दृश्य वर्ण हैं।", + "invalidGitUrl": "अमान्य Git URL प्रारूप।", + "duplicateUrl": "इस URL वाला स्रोत पहले से मौजूद है।", + "nameTooLong": "नाम 20 वर्णों से अधिक नहीं हो सकता।", + "nonVisibleCharsName": "नाम में गैर-दृश्य वर्ण हैं।", + "duplicateName": "इस नाम वाला स्रोत पहले से मौजूद है।" + }, + "add": { + "namePlaceholder": "वैकल्पिक स्रोत नाम (जैसे 'मेरा निजी रेपो')", + "urlPlaceholder": "Git रिपॉजिटरी URL (जैसे 'https://github.com/user/repo.git')", + "urlFormats": "समर्थित प्रारूप: HTTPS, SSH, या स्थानीय फ़ाइल पथ।", + "button": "स्रोत जोड़ें" + }, + "current": { + "title": "वर्तमान स्रोत", + "empty": "अभी तक कोई मार्केटप्लेस स्रोत नहीं जोड़ा गया है।", + "emptyHint": "मार्केटप्लेस आइटम ब्राउज़ करने के लिए ऊपर एक स्रोत जोड़ें।", + "refresh": "स्रोत रीफ़्रेश करें", + "remove": "स्रोत हटाएं" + } + }, + "tabs": { + "browse": "ब्राउज़ करें", + "sources": "स्रोत" + }, + "title": "मार्केटप्लेस", + "items": { + "refresh": { + "refreshing": "मार्केटप्लेस आइटम रीफ़्रेश हो रहे हैं..." + }, + "empty": { + "noItems": "कोई मार्केटप्लेस आइटम नहीं मिला।", + "emptyHint": "अपने फ़िल्टर या खोज शब्दों को समायोजित करने का प्रयास करें" + }, + "count": "{{count}} आइटम" + } +} diff --git a/src/i18n/locales/it/marketplace.json b/src/i18n/locales/it/marketplace.json new file mode 100644 index 0000000000..8e8b4dc423 --- /dev/null +++ b/src/i18n/locales/it/marketplace.json @@ -0,0 +1,100 @@ +{ + "type-group": { + "modes": "Modalità", + "mcps": "Server MCP", + "prompts": "Prompt", + "packages": "Pacchetti", + "generic-type": "{{type}}", + "match": "corrispondenza" + }, + "item-card": { + "type-mode": "Modalità", + "type-mcp": "Server MCP", + "type-prompt": "Prompt", + "type-package": "Pacchetto", + "type-other": "Altro", + "by-author": "di {{author}}", + "authors-profile": "Profilo dell'autore", + "remove-tag-filter": "Rimuovi filtro tag: {{tag}}", + "filter-by-tag": "Filtra per tag: {{tag}}", + "component-details": "Dettagli componente", + "match-count": "{{count}} corrispondenza{{count !== 1 ? 'e' : ''}}", + "view": "Visualizza", + "source": "Sorgente" + }, + "install-sidebar": { + "title": "Installa {{itemName}}", + "installButton": "Installa", + "cancelButton": "Annulla" + }, + "filters": { + "search": { + "placeholder": "Cerca nel marketplace..." + }, + "type": { + "label": "Tipo", + "all": "Tutti i tipi", + "mode": "Modalità", + "mcp server": "Server MCP", + "prompt": "Prompt", + "package": "Pacchetto" + }, + "sort": { + "label": "Ordina per", + "name": "Nome", + "lastUpdated": "Ultimo aggiornamento" + }, + "tags": { + "label": "Tag", + "clear_one": "Cancella {{count}} tag selezionato", + "clear_other": "Cancella {{count}} tag selezionati", + "placeholder": "Cerca tag...", + "noResults": "Nessun tag trovato.", + "selected_one": "{{count}} tag selezionato", + "selected_other": "{{count}} tag selezionati" + } + }, + "sources": { + "title": "Sorgenti del marketplace", + "description": "Aggiungi o gestisci le sorgenti per gli elementi del marketplace. Ogni sorgente è un repository Git contenente definizioni di elementi del marketplace.", + "errors": { + "maxSources": "Massimo {{max}} sorgenti consentite.", + "emptyUrl": "L'URL non può essere vuoto.", + "nonVisibleChars": "L'URL contiene caratteri non visibili.", + "invalidGitUrl": "Formato URL Git non valido.", + "duplicateUrl": "Esiste già una sorgente con questo URL.", + "nameTooLong": "Il nome non può superare i 20 caratteri.", + "nonVisibleCharsName": "Il nome contiene caratteri non visibili.", + "duplicateName": "Esiste già una sorgente con questo nome." + }, + "add": { + "namePlaceholder": "Nome sorgente opzionale (es. 'Il mio repository privato')", + "urlPlaceholder": "URL repository Git (es. 'https://github.com/user/repo.git')", + "urlFormats": "Formati supportati: HTTPS, SSH o percorso file locale.", + "button": "Aggiungi sorgente" + }, + "current": { + "title": "Sorgenti attuali", + "empty": "Nessuna sorgente del marketplace aggiunta ancora.", + "emptyHint": "Aggiungi una sorgente sopra per sfogliare gli elementi del marketplace.", + "refresh": "Aggiorna sorgente", + "remove": "Rimuovi sorgente" + } + }, + "tabs": { + "browse": "Sfoglia", + "sources": "Sorgenti" + }, + "title": "Marketplace", + "items": { + "refresh": { + "refreshing": "Aggiornamento elementi del marketplace..." + }, + "empty": { + "noItems": "Nessun elemento del marketplace trovato.", + "emptyHint": "Prova a regolare i filtri o i termini di ricerca" + }, + "count_one": "{{count}} elemento", + "count_other": "{{count}} elementi" + } +} diff --git a/src/i18n/locales/ja/marketplace.json b/src/i18n/locales/ja/marketplace.json new file mode 100644 index 0000000000..358e6214ed --- /dev/null +++ b/src/i18n/locales/ja/marketplace.json @@ -0,0 +1,97 @@ +{ + "type-group": { + "modes": "モード", + "mcps": "MCPサーバー", + "prompts": "プロンプト", + "packages": "パッケージ", + "generic-type": "{{type}}", + "match": "一致" + }, + "item-card": { + "type-mode": "モード", + "type-mcp": "MCPサーバー", + "type-prompt": "プロンプト", + "type-package": "パッケージ", + "type-other": "その他", + "by-author": "作成者:{{author}}", + "authors-profile": "作成者のプロフィール", + "remove-tag-filter": "タグフィルターを削除:{{tag}}", + "filter-by-tag": "タグでフィルター:{{tag}}", + "component-details": "コンポーネントの詳細", + "match-count": "{{count}}件の一致", + "view": "表示", + "source": "ソース" + }, + "install-sidebar": { + "title": "{{itemName}}をインストール", + "installButton": "インストール", + "cancelButton": "キャンセル" + }, + "filters": { + "search": { + "placeholder": "マーケットプレイスを検索..." + }, + "type": { + "label": "タイプ", + "all": "すべてのタイプ", + "mode": "モード", + "mcp server": "MCPサーバー", + "prompt": "プロンプト", + "package": "パッケージ" + }, + "sort": { + "label": "並べ替え", + "name": "名前", + "lastUpdated": "最終更新" + }, + "tags": { + "label": "タグ", + "clear": "{{count}}個の選択されたタグをクリア", + "placeholder": "タグを検索...", + "noResults": "タグが見つかりませんでした。", + "selected": "{{count}}個のタグを選択" + } + }, + "sources": { + "title": "マーケットプレイスソース", + "description": "マーケットプレイスアイテムのソースを追加または管理します。各ソースは、マーケットプレイスアイテムの定義を含むGitリポジトリです。", + "errors": { + "maxSources": "最大{{max}}個のソースが許可されています。", + "emptyUrl": "URLは空にできません。", + "nonVisibleChars": "URLに非表示文字が含まれています。", + "invalidGitUrl": "無効なGit URL形式。", + "duplicateUrl": "このURLを持つソースはすでに存在します。", + "nameTooLong": "名前は20文字を超えることはできません。", + "nonVisibleCharsName": "名前に非表示文字が含まれています。", + "duplicateName": "この名前を持つソースはすでに存在します。" + }, + "add": { + "namePlaceholder": "オプションのソース名(例:'私のプライベートリポジトリ')", + "urlPlaceholder": "GitリポジトリURL(例:'https://github.com/user/repo.git')", + "urlFormats": "サポートされている形式:HTTPS、SSH、またはローカルファイルパス。", + "button": "ソースを追加" + }, + "current": { + "title": "現在のソース", + "empty": "まだマーケットプレイスソースが追加されていません。", + "emptyHint": "マーケットプレイスアイテムを閲覧するには、上にソースを追加してください。", + "refresh": "ソースを更新", + "remove": "ソースを削除" + } + }, + "tabs": { + "browse": "閲覧", + "sources": "ソース" + }, + "title": "マーケットプレイス", + "items": { + "refresh": { + "refreshing": "マーケットプレイスアイテムを更新中..." + }, + "empty": { + "noItems": "マーケットプレイスアイテムが見つかりませんでした。", + "emptyHint": "フィルターまたは検索語句を調整してみてください" + }, + "count": "{{count}}個のアイテム" + } +} diff --git a/src/i18n/locales/ko/marketplace.json b/src/i18n/locales/ko/marketplace.json new file mode 100644 index 0000000000..f55114eacd --- /dev/null +++ b/src/i18n/locales/ko/marketplace.json @@ -0,0 +1,97 @@ +{ + "type-group": { + "modes": "모드", + "mcps": "MCP 서버", + "prompts": "프롬프트", + "packages": "패키지", + "generic-type": "{{type}}", + "match": "일치" + }, + "item-card": { + "type-mode": "모드", + "type-mcp": "MCP 서버", + "type-prompt": "프롬프트", + "type-package": "패키지", + "type-other": "기타", + "by-author": "작성자: {{author}}", + "authors-profile": "작성자 프로필", + "remove-tag-filter": "태그 필터 제거: {{tag}}", + "filter-by-tag": "태그로 필터링: {{tag}}", + "component-details": "컴포넌트 상세정보", + "match-count": "{{count}}개 일치", + "view": "보기", + "source": "소스" + }, + "install-sidebar": { + "title": "{{itemName}} 설치", + "installButton": "설치", + "cancelButton": "취소" + }, + "filters": { + "search": { + "placeholder": "마켓플레이스 검색..." + }, + "type": { + "label": "유형", + "all": "모든 유형", + "mode": "모드", + "mcp server": "MCP 서버", + "prompt": "프롬프트", + "package": "패키지" + }, + "sort": { + "label": "정렬 기준", + "name": "이름", + "lastUpdated": "최종 업데이트" + }, + "tags": { + "label": "태그", + "clear": "선택된 태그 {{count}}개 지우기", + "placeholder": "태그 검색...", + "noResults": "태그를 찾을 수 없습니다.", + "selected": "태그 {{count}}개 선택됨" + } + }, + "sources": { + "title": "마켓플레이스 소스", + "description": "마켓플레이스 항목의 소스를 추가하거나 관리합니다. 각 소스는 마켓플레이스 항목 정의를 포함하는 Git 리포지토리입니다.", + "errors": { + "maxSources": "최대 {{max}}개의 소스가 허용됩니다.", + "emptyUrl": "URL은 비워둘 수 없습니다.", + "nonVisibleChars": "URL에 보이지 않는 문자가 포함되어 있습니다.", + "invalidGitUrl": "잘못된 Git URL 형식입니다.", + "duplicateUrl": "이 URL을 가진 소스가 이미 존재합니다.", + "nameTooLong": "이름은 20자를 초과할 수 없습니다.", + "nonVisibleCharsName": "이름에 보이지 않는 문자가 포함되어 있습니다.", + "duplicateName": "이 이름을 가진 소스가 이미 존재합니다." + }, + "add": { + "namePlaceholder": "선택적 소스 이름 (예: '내 개인 리포지토리')", + "urlPlaceholder": "Git 리포지토리 URL (예: 'https://github.com/user/repo.git')", + "urlFormats": "지원되는 형식: HTTPS, SSH 또는 로컬 파일 경로.", + "button": "소스 추가" + }, + "current": { + "title": "현재 소스", + "empty": "아직 마켓플레이스 소스가 추가되지 않았습니다.", + "emptyHint": "마켓플레이스 항목을 탐색하려면 위에 소스를 추가하세요.", + "refresh": "소스 새로고침", + "remove": "소스 제거" + } + }, + "tabs": { + "browse": "찾아보기", + "sources": "소스" + }, + "title": "마켓플레이스", + "items": { + "refresh": { + "refreshing": "마켓플레이스 항목 새로고침 중..." + }, + "empty": { + "noItems": "마켓플레이스 항목을 찾을 수 없습니다.", + "emptyHint": "필터 또는 검색어를 조정해 보세요" + }, + "count": "{{count}}개 항목" + } +} diff --git a/src/i18n/locales/pl/marketplace.json b/src/i18n/locales/pl/marketplace.json new file mode 100644 index 0000000000..54dbf0e176 --- /dev/null +++ b/src/i18n/locales/pl/marketplace.json @@ -0,0 +1,103 @@ +{ + "type-group": { + "modes": "Tryby", + "mcps": "Serwery MCP", + "prompts": "Podpowiedzi", + "packages": "Pakiety", + "generic-type": "{{type}}y", + "match": "dopasowanie" + }, + "item-card": { + "type-mode": "Tryb", + "type-mcp": "Serwer MCP", + "type-prompt": "Podpowiedź", + "type-package": "Pakiet", + "type-other": "Inne", + "by-author": "autor: {{author}}", + "authors-profile": "Profil autora", + "remove-tag-filter": "Usuń filtr tagu: {{tag}}", + "filter-by-tag": "Filtruj po tagu: {{tag}}", + "component-details": "Szczegóły komponentu", + "match-count": "{{count}} dopasowani{{count === 1 ? 'e' : count < 5 ? 'a' : 'ń'}}", + "view": "Pokaż", + "source": "Źródło" + }, + "install-sidebar": { + "title": "Zainstaluj {{itemName}}", + "installButton": "Zainstaluj", + "cancelButton": "Anuluj" + }, + "filters": { + "search": { + "placeholder": "Przeszukaj marketplace..." + }, + "type": { + "label": "Typ", + "all": "Wszystkie typy", + "mode": "Tryb", + "mcp server": "Serwer MCP", + "prompt": "Podpowiedź", + "package": "Pakiet" + }, + "sort": { + "label": "Sortuj według", + "name": "Nazwa", + "lastUpdated": "Ostatnia aktualizacja" + }, + "tags": { + "label": "Tagi", + "clear_one": "Wyczyść {{count}} wybrany tag", + "clear_few": "Wyczyść {{count}} wybrane tagi", + "clear_many": "Wyczyść {{count}} wybranych tagów", + "placeholder": "Szukaj tagów...", + "noResults": "Nie znaleziono tagów.", + "selected_one": "{{count}} wybrany tag", + "selected_few": "{{count}} wybrane tagi", + "selected_many": "{{count}} wybranych tagów" + } + }, + "sources": { + "title": "Źródła marketplace", + "description": "Dodaj lub zarządzaj źródłami elementów marketplace. Każde źródło to repozytorium Git zawierające definicje elementów marketplace.", + "errors": { + "maxSources": "Maksymalnie {{max}} źródeł dozwolonych.", + "emptyUrl": "URL nie może być pusty.", + "nonVisibleChars": "URL zawiera niewidoczne znaki.", + "invalidGitUrl": "Nieprawidłowy format URL Git.", + "duplicateUrl": "Źródło z tym URL już istnieje.", + "nameTooLong": "Nazwa nie może przekraczać 20 znaków.", + "nonVisibleCharsName": "Nazwa zawiera niewidoczne znaki.", + "duplicateName": "Źródło z tą nazwą już istnieje." + }, + "add": { + "namePlaceholder": "Opcjonalna nazwa źródła (np. 'Moje prywatne repo')", + "urlPlaceholder": "URL repozytorium Git (np. 'https://github.com/user/repo.git')", + "urlFormats": "Obsługiwane formaty: HTTPS, SSH lub lokalna ścieżka pliku.", + "button": "Dodaj źródło" + }, + "current": { + "title": "Aktualne źródła", + "empty": "Nie dodano jeszcze żadnych źródeł marketplace.", + "emptyHint": "Dodaj źródło powyżej, aby przeglądać elementy marketplace.", + "refresh": "Odśwież źródło", + "remove": "Usuń źródło" + } + }, + "tabs": { + "browse": "Przeglądaj", + "sources": "Źródła" + }, + "title": "Marketplace", + "items": { + "refresh": { + "refreshing": "Odświeżanie elementów marketplace..." + }, + "empty": { + "noItems": "Nie znaleziono elementów marketplace.", + "emptyHint": "Spróbuj dostosować filtry lub wyszukiwane hasła" + }, + "count_one": "{{count}} element", + "count_few": "{{count}} elementy", + "count_many": "{{count}} elementów" + } +} diff --git a/src/i18n/locales/pt-BR/marketplace.json b/src/i18n/locales/pt-BR/marketplace.json new file mode 100644 index 0000000000..2caaba97aa --- /dev/null +++ b/src/i18n/locales/pt-BR/marketplace.json @@ -0,0 +1,100 @@ +{ + "type-group": { + "modes": "Modos", + "mcps": "Servidores MCP", + "prompts": "Prompts", + "packages": "Pacotes", + "generic-type": "{{type}}s", + "match": "correspondência" + }, + "item-card": { + "type-mode": "Modo", + "type-mcp": "Servidor MCP", + "type-prompt": "Prompt", + "type-package": "Pacote", + "type-other": "Outro", + "by-author": "por {{author}}", + "authors-profile": "Perfil do Autor", + "remove-tag-filter": "Remover filtro de tag: {{tag}}", + "filter-by-tag": "Filtrar por tag: {{tag}}", + "component-details": "Detalhes do Componente", + "match-count": "{{count}} correspondência{{count !== 1 ? 's' : ''}}", + "view": "Visualizar", + "source": "Fonte" + }, + "install-sidebar": { + "title": "Instalar {{itemName}}", + "installButton": "Instalar", + "cancelButton": "Cancelar" + }, + "filters": { + "search": { + "placeholder": "Buscar no marketplace..." + }, + "type": { + "label": "Tipo", + "all": "Todos os tipos", + "mode": "Modo", + "mcp server": "Servidor MCP", + "prompt": "Prompt", + "package": "Pacote" + }, + "sort": { + "label": "Ordenar por", + "name": "Nome", + "lastUpdated": "Última atualização" + }, + "tags": { + "label": "Tags", + "clear_one": "Limpar {{count}} tag selecionada", + "clear_other": "Limpar {{count}} tags selecionadas", + "placeholder": "Buscar tags...", + "noResults": "Nenhuma tag encontrada.", + "selected_one": "{{count}} tag selecionada", + "selected_other": "{{count}} tags selecionadas" + } + }, + "sources": { + "title": "Fontes do marketplace", + "description": "Adicione ou gerencie fontes para os itens do marketplace. Cada fonte é um repositório Git contendo definições de itens do marketplace.", + "errors": { + "maxSources": "Máximo de {{max}} fontes permitidas.", + "emptyUrl": "A URL não pode estar vazia.", + "nonVisibleChars": "A URL contém caracteres não visíveis.", + "invalidGitUrl": "Formato de URL Git inválido.", + "duplicateUrl": "Uma fonte com esta URL já existe.", + "nameTooLong": "O nome não pode exceder 20 caracteres.", + "nonVisibleCharsName": "O nome contém caracteres não visíveis.", + "duplicateName": "Uma fonte com este nome já existe." + }, + "add": { + "namePlaceholder": "Nome opcional da fonte (ex: 'Meu Repositório Privado')", + "urlPlaceholder": "URL do repositório Git (ex: 'https://github.com/user/repo.git')", + "urlFormats": "Formatos suportados: HTTPS, SSH ou caminho de arquivo local.", + "button": "Adicionar fonte" + }, + "current": { + "title": "Fontes atuais", + "empty": "Nenhuma fonte do marketplace adicionada ainda.", + "emptyHint": "Adicione uma fonte acima para navegar pelos itens do marketplace.", + "refresh": "Atualizar fonte", + "remove": "Remover fonte" + } + }, + "tabs": { + "browse": "Navegar", + "sources": "Fontes" + }, + "title": "Marketplace", + "items": { + "refresh": { + "refreshing": "Atualizando itens do marketplace..." + }, + "empty": { + "noItems": "Nenhum item do marketplace encontrado.", + "emptyHint": "Tente ajustar seus filtros ou termos de busca" + }, + "count_one": "{{count}} item", + "count_other": "{{count}} itens" + } +} diff --git a/src/i18n/locales/ru/marketplace.json b/src/i18n/locales/ru/marketplace.json new file mode 100644 index 0000000000..f79cee14aa --- /dev/null +++ b/src/i18n/locales/ru/marketplace.json @@ -0,0 +1,105 @@ +{ + "type-group": { + "modes": "Режимы", + "mcps": "MCP-серверы", + "prompts": "Промпты", + "packages": "Пакеты", + "generic-type": "{{type}}", + "match": "совпадение" + }, + "item-card": { + "type-mode": "Режим", + "type-mcp": "MCP-сервер", + "type-prompt": "Промпт", + "type-package": "Пакет", + "type-other": "Другое", + "by-author": "от {{author}}", + "authors-profile": "Профиль автора", + "remove-tag-filter": "Удалить фильтр по тегу: {{tag}}", + "filter-by-tag": "Фильтровать по тегу: {{tag}}", + "component-details": "Детали компонента", + "match-count_one": "{{count}} совпадение", + "match-count_few": "{{count}} совпадения", + "match-count_many": "{{count}} совпадений", + "view": "Просмотр", + "source": "Источник" + }, + "install-sidebar": { + "title": "Установить {{itemName}}", + "installButton": "Установить", + "cancelButton": "Отмена" + }, + "filters": { + "search": { + "placeholder": "Поиск по маркетплейсу..." + }, + "type": { + "label": "Тип", + "all": "Все типы", + "mode": "Режим", + "mcp server": "MCP-сервер", + "prompt": "Промпт", + "package": "Пакет" + }, + "sort": { + "label": "Сортировать по", + "name": "Имя", + "lastUpdated": "Последнее обновление" + }, + "tags": { + "label": "Теги", + "clear_one": "Очистить {{count}} выбранный тег", + "clear_few": "Очистить {{count}} выбранных тега", + "clear_many": "Очистить {{count}} выбранных тегов", + "placeholder": "Поиск тегов...", + "noResults": "Теги не найдены.", + "selected_one": "{{count}} выбранный тег", + "selected_few": "{{count}} выбранных тега", + "selected_many": "{{count}} выбранных тегов" + } + }, + "sources": { + "title": "Источники маркетплейса", + "description": "Добавляйте или управляйте источниками элементов маркетплейса. Каждый источник — это репозиторий Git, содержащий определения элементов маркетплейса.", + "errors": { + "maxSources": "Разрешено не более {{max}} источников.", + "emptyUrl": "URL не может быть пустым.", + "nonVisibleChars": "URL содержит невидимые символы.", + "invalidGitUrl": "Неверный формат URL Git.", + "duplicateUrl": "Источник с таким URL уже существует.", + "nameTooLong": "Имя не может превышать 20 символов.", + "nonVisibleCharsName": "Имя содержит невидимые символы.", + "duplicateName": "Источник с таким именем уже существует." + }, + "add": { + "namePlaceholder": "Необязательное имя источника (например, 'Мой приватный репозиторий')", + "urlPlaceholder": "URL репозитория Git (например, 'https://github.com/user/repo.git')", + "urlFormats": "Поддерживаемые форматы: HTTPS, SSH или локальный путь к файлу.", + "button": "Добавить источник" + }, + "current": { + "title": "Текущие источники", + "empty": "Источники маркетплейса еще не добавлены.", + "emptyHint": "Добавьте источник выше, чтобы просмотреть элементы маркетплейса.", + "refresh": "Обновить источник", + "remove": "Удалить источник" + } + }, + "tabs": { + "browse": "Обзор", + "sources": "Источники" + }, + "title": "Маркетплейс", + "items": { + "refresh": { + "refreshing": "Обновление элементов маркетплейса..." + }, + "empty": { + "noItems": "Элементы маркетплейса не найдены.", + "emptyHint": "Попробуйте изменить фильтры или условия поиска" + }, + "count_one": "{{count}} элемент", + "count_few": "{{count}} элемента", + "count_many": "{{count}} элементов" + } +} diff --git a/src/i18n/locales/tr/marketplace.json b/src/i18n/locales/tr/marketplace.json new file mode 100644 index 0000000000..2f60effb46 --- /dev/null +++ b/src/i18n/locales/tr/marketplace.json @@ -0,0 +1,97 @@ +{ + "type-group": { + "modes": "Modlar", + "mcps": "MCP Sunucuları", + "prompts": "Komutlar", + "packages": "Paketler", + "generic-type": "{{type}}lar", + "match": "eşleşme" + }, + "item-card": { + "type-mode": "Mod", + "type-mcp": "MCP Sunucusu", + "type-prompt": "Komut", + "type-package": "Paket", + "type-other": "Diğer", + "by-author": "yazar: {{author}}", + "authors-profile": "Yazar Profili", + "remove-tag-filter": "Etiket filtresini kaldır: {{tag}}", + "filter-by-tag": "Etikete göre filtrele: {{tag}}", + "component-details": "Bileşen Detayları", + "match-count": "{{count}} eşleşme", + "view": "Görüntüle", + "source": "Kaynak" + }, + "install-sidebar": { + "title": "{{itemName}} Yükle", + "installButton": "Yükle", + "cancelButton": "İptal" + }, + "filters": { + "search": { + "placeholder": "Marketplace ara..." + }, + "type": { + "label": "Tip", + "all": "Tüm Tipler", + "mode": "Mod", + "mcp server": "MCP Sunucusu", + "prompt": "Komut", + "package": "Paket" + }, + "sort": { + "label": "Sırala", + "name": "Ad", + "lastUpdated": "Son Güncelleme" + }, + "tags": { + "label": "Etiketler", + "clear": "{{count}} seçili etiketi temizle", + "placeholder": "Etiket ara...", + "noResults": "Etiket bulunamadı.", + "selected": "{{count}} etiket seçili" + } + }, + "sources": { + "title": "Marketplace Kaynakları", + "description": "Marketplace öğeleri için kaynak ekleyin veya yönetin. Her kaynak, marketplace öğe tanımlarını içeren bir Git deposudur.", + "errors": { + "maxSources": "Maksimum {{max}} kaynağa izin verilir.", + "emptyUrl": "URL boş olamaz.", + "nonVisibleChars": "URL görünmez karakterler içeriyor.", + "invalidGitUrl": "Geçersiz Git URL formatı.", + "duplicateUrl": "Bu URL'ye sahip bir kaynak zaten var.", + "nameTooLong": "Ad 20 karakteri geçemez.", + "nonVisibleCharsName": "Ad görünmez karakterler içeriyor.", + "duplicateName": "Bu ada sahip bir kaynak zaten var." + }, + "add": { + "namePlaceholder": "İsteğe bağlı kaynak adı (örn. 'Özel Depom')", + "urlPlaceholder": "Git deposu URL'si (örn. 'https://github.com/user/repo.git')", + "urlFormats": "Desteklenen formatlar: HTTPS, SSH veya yerel dosya yolu.", + "button": "Kaynak Ekle" + }, + "current": { + "title": "Mevcut Kaynaklar", + "empty": "Henüz marketplace kaynağı eklenmedi.", + "emptyHint": "Marketplace öğelerine göz atmak için yukarıdan bir kaynak ekleyin.", + "refresh": "Kaynağı yenile", + "remove": "Kaynağı kaldır" + } + }, + "tabs": { + "browse": "Göz At", + "sources": "Kaynaklar" + }, + "title": "Marketplace", + "items": { + "refresh": { + "refreshing": "Marketplace öğeleri yenileniyor..." + }, + "empty": { + "noItems": "Marketplace öğesi bulunamadı.", + "emptyHint": "Filtrelerinizi veya arama terimlerinizi ayarlamayı deneyin" + }, + "count": "{{count}} öğe" + } +} diff --git a/src/i18n/locales/vi/marketplace.json b/src/i18n/locales/vi/marketplace.json new file mode 100644 index 0000000000..965d5565ec --- /dev/null +++ b/src/i18n/locales/vi/marketplace.json @@ -0,0 +1,97 @@ +{ + "type-group": { + "modes": "Chế độ", + "mcps": "Máy chủ MCP", + "prompts": "Gợi ý", + "packages": "Gói", + "generic-type": "{{type}}", + "match": "phù hợp" + }, + "item-card": { + "type-mode": "Chế độ", + "type-mcp": "Máy chủ MCP", + "type-prompt": "Gợi ý", + "type-package": "Gói", + "type-other": "Khác", + "by-author": "bởi {{author}}", + "authors-profile": "Hồ sơ tác giả", + "remove-tag-filter": "Xóa bộ lọc thẻ: {{tag}}", + "filter-by-tag": "Lọc theo thẻ: {{tag}}", + "component-details": "Chi tiết thành phần", + "match-count": "{{count}} kết quả phù hợp", + "view": "Xem", + "source": "Nguồn" + }, + "install-sidebar": { + "title": "Cài đặt {{itemName}}", + "installButton": "Cài đặt", + "cancelButton": "Hủy" + }, + "filters": { + "search": { + "placeholder": "Tìm kiếm marketplace..." + }, + "type": { + "label": "Loại", + "all": "Tất cả các loại", + "mode": "Chế độ", + "mcp server": "Máy chủ MCP", + "prompt": "Gợi ý", + "package": "Gói" + }, + "sort": { + "label": "Sắp xếp theo", + "name": "Tên", + "lastUpdated": "Cập nhật lần cuối" + }, + "tags": { + "label": "Thẻ", + "clear": "Xóa {{count}} thẻ đã chọn", + "placeholder": "Tìm kiếm thẻ...", + "noResults": "Không tìm thấy thẻ nào.", + "selected": "{{count}} thẻ đã chọn" + } + }, + "sources": { + "title": "Nguồn marketplace", + "description": "Thêm hoặc quản lý các nguồn cho các mục marketplace. Mỗi nguồn là một kho lưu trữ Git chứa các định nghĩa mục marketplace.", + "errors": { + "maxSources": "Tối đa {{max}} nguồn được phép.", + "emptyUrl": "URL không được để trống.", + "nonVisibleChars": "URL chứa các ký tự không hiển thị.", + "invalidGitUrl": "Định dạng URL Git không hợp lệ.", + "duplicateUrl": "Nguồn với URL này đã tồn tại.", + "nameTooLong": "Tên không được vượt quá 20 ký tự.", + "nonVisibleCharsName": "Tên chứa các ký tự không hiển thị.", + "duplicateName": "Nguồn với tên này đã tồn tại." + }, + "add": { + "namePlaceholder": "Tên nguồn tùy chọn (ví dụ: 'Kho lưu trữ riêng của tôi')", + "urlPlaceholder": "URL kho lưu trữ Git (ví dụ: 'https://github.com/user/repo.git')", + "urlFormats": "Các định dạng được hỗ trợ: HTTPS, SSH hoặc đường dẫn tệp cục bộ.", + "button": "Thêm nguồn" + }, + "current": { + "title": "Nguồn hiện tại", + "empty": "Chưa có nguồn marketplace nào được thêm.", + "emptyHint": "Thêm nguồn ở trên để duyệt các mục marketplace.", + "refresh": "Làm mới nguồn", + "remove": "Xóa nguồn" + } + }, + "tabs": { + "browse": "Duyệt", + "sources": "Nguồn" + }, + "title": "Marketplace", + "items": { + "refresh": { + "refreshing": "Đang làm mới các mục marketplace..." + }, + "empty": { + "noItems": "Không tìm thấy mục marketplace nào.", + "emptyHint": "Thử điều chỉnh bộ lọc hoặc cụm từ tìm kiếm của bạn" + }, + "count": "{{count}} mục" + } +} diff --git a/src/i18n/locales/zh-CN/marketplace.json b/src/i18n/locales/zh-CN/marketplace.json new file mode 100644 index 0000000000..5ae8480575 --- /dev/null +++ b/src/i18n/locales/zh-CN/marketplace.json @@ -0,0 +1,97 @@ +{ + "type-group": { + "modes": "模式", + "mcps": "MCP服务器", + "prompts": "提示", + "packages": "包", + "generic-type": "{{type}}", + "match": "匹配" + }, + "item-card": { + "type-mode": "模式", + "type-mcp": "MCP服务器", + "type-prompt": "提示", + "type-package": "包", + "type-other": "其他", + "by-author": "作者:{{author}}", + "authors-profile": "作者主页", + "remove-tag-filter": "移除标签过滤器:{{tag}}", + "filter-by-tag": "按标签过滤:{{tag}}", + "component-details": "组件详情", + "match-count": "{{count}}个匹配", + "view": "查看", + "source": "源码" + }, + "install-sidebar": { + "title": "安装 {{itemName}}", + "installButton": "安装", + "cancelButton": "取消" + }, + "filters": { + "search": { + "placeholder": "搜索应用市场..." + }, + "type": { + "label": "类型", + "all": "所有类型", + "mode": "模式", + "mcp server": "MCP 服务", + "prompt": "提示词", + "package": "包" + }, + "sort": { + "label": "排序方式", + "name": "名称", + "lastUpdated": "最后更新" + }, + "tags": { + "label": "标签", + "clear": "清除 {{count}} 个已选标签", + "placeholder": "搜索标签...", + "noResults": "未找到标签。", + "selected": "已选 {{count}} 个标签" + } + }, + "sources": { + "title": "应用市场源", + "description": "添加或管理应用市场项的来源。每个来源都是一个包含应用市场项定义的 Git 仓库。", + "errors": { + "maxSources": "最多允许 {{max}} 个来源。", + "emptyUrl": "URL 不能为空。", + "nonVisibleChars": "URL 包含不可见字符。", + "invalidGitUrl": "无效的 Git URL 格式。", + "duplicateUrl": "具有此 URL 的来源已存在。", + "nameTooLong": "名称不能超过 20 个字符。", + "nonVisibleCharsName": "名称包含不可见字符。", + "duplicateName": "具有此名称的来源已存在。" + }, + "add": { + "namePlaceholder": "可选来源名称(例如 '我的私有仓库')", + "urlPlaceholder": "Git 仓库 URL(例如 'https://github.com/user/repo.git')", + "urlFormats": "支持的格式:HTTPS、SSH 或本地文件路径。", + "button": "添加来源" + }, + "current": { + "title": "当前来源", + "empty": "尚未添加应用市场来源。", + "emptyHint": "在上方添加来源以浏览应用市场项。", + "refresh": "刷新来源", + "remove": "移除来源" + } + }, + "tabs": { + "browse": "浏览", + "sources": "来源" + }, + "title": "应用市场", + "items": { + "refresh": { + "refreshing": "正在刷新应用市场项..." + }, + "empty": { + "noItems": "未找到应用市场项。", + "emptyHint": "尝试调整您的过滤器或搜索词" + }, + "count": "{{count}} 项" + } +} diff --git a/src/i18n/locales/zh-TW/marketplace.json b/src/i18n/locales/zh-TW/marketplace.json new file mode 100644 index 0000000000..fede6ec69c --- /dev/null +++ b/src/i18n/locales/zh-TW/marketplace.json @@ -0,0 +1,97 @@ +{ + "type-group": { + "modes": "模式", + "mcps": "MCP伺服器", + "prompts": "提示", + "packages": "套件", + "generic-type": "{{type}}", + "match": "符合" + }, + "item-card": { + "type-mode": "模式", + "type-mcp": "MCP伺服器", + "type-prompt": "提示", + "type-package": "套件", + "type-other": "其他", + "by-author": "作者:{{author}}", + "authors-profile": "作者個人檔案", + "remove-tag-filter": "移除標籤篩選:{{tag}}", + "filter-by-tag": "依標籤篩選:{{tag}}", + "component-details": "元件詳細資訊", + "match-count": "{{count}}個符合", + "view": "檢視", + "source": "原始碼" + }, + "install-sidebar": { + "title": "安裝 {{itemName}}", + "installButton": "安裝", + "cancelButton": "取消" + }, + "filters": { + "search": { + "placeholder": "搜尋市集..." + }, + "type": { + "label": "類型", + "all": "所有類型", + "mode": "模式", + "mcp server": "MCP 伺服器", + "prompt": "提示", + "package": "套件" + }, + "sort": { + "label": "排序依據", + "name": "名稱", + "lastUpdated": "上次更新" + }, + "tags": { + "label": "標籤", + "clear": "清除 {{count}} 個選取的標籤", + "placeholder": "搜尋標籤...", + "noResults": "找不到標籤。", + "selected": "已選取 {{count}} 個標籤" + } + }, + "sources": { + "title": "市集來源", + "description": "新增或管理市集項目的來源。每個來源都是一個包含市集項目定義的 Git 儲存庫。", + "errors": { + "maxSources": "最多允許 {{max}} 個來源。", + "emptyUrl": "URL 不能為空。", + "nonVisibleChars": "URL 包含不可見字元。", + "invalidGitUrl": "無效的 Git URL 格式。", + "duplicateUrl": "具有此 URL 的來源已存在。", + "nameTooLong": "名稱不能超過 20 個字元。", + "nonVisibleCharsName": "名稱包含不可見字元。", + "duplicateName": "具有此名稱的來源已存在。" + }, + "add": { + "namePlaceholder": "選用來源名稱(例如 '我的私人儲存庫')", + "urlPlaceholder": "Git 儲存庫 URL(例如 'https://github.com/user/repo.git')", + "urlFormats": "支援的格式:HTTPS、SSH 或本機檔案路徑。", + "button": "新增來源" + }, + "current": { + "title": "目前來源", + "empty": "尚未新增市集來源。", + "emptyHint": "在上方新增來源以瀏覽市集項目。", + "refresh": "重新整理來源", + "remove": "移除來源" + } + }, + "tabs": { + "browse": "瀏覽", + "sources": "來源" + }, + "title": "市集", + "items": { + "refresh": { + "refreshing": "正在重新整理市集項目..." + }, + "empty": { + "noItems": "找不到市集項目。", + "emptyHint": "嘗試調整您的篩選器或搜尋詞" + }, + "count": "{{count}} 個項目" + } +} diff --git a/src/package.json b/src/package.json index c98d7f8537..dce86dc3c6 100644 --- a/src/package.json +++ b/src/package.json @@ -90,6 +90,11 @@ "title": "%command.history.title%", "icon": "$(history)" }, + { + "command": "roo-cline.marketplaceButtonClicked", + "title": "Marketplace", + "icon": "$(extensions)" + }, { "command": "roo-cline.popoutButtonClicked", "title": "%command.openInEditor.title%", @@ -232,6 +237,11 @@ "command": "roo-cline.settingsButtonClicked", "group": "navigation@6", "when": "view == roo-cline.SidebarProvider" + }, + { + "command": "roo-cline.marketplaceButtonClicked", + "group": "navigation@7", + "when": "view == roo-cline.SidebarProvider" } ], "editor/title": [ @@ -264,6 +274,11 @@ "command": "roo-cline.settingsButtonClicked", "group": "navigation@6", "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" + }, + { + "command": "roo-cline.marketplaceButtonClicked", + "group": "navigation@7", + "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" } ] }, diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 4fb893ae1f..7fe5edb405 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -75,6 +75,8 @@ export const commandIds = [ "focusInput", "acceptInput", + + "marketplaceButtonClicked", ] as const export type CommandId = (typeof commandIds)[number] @@ -851,6 +853,15 @@ export const globalSettingsSchema = z.object({ customModePrompts: customModePromptsSchema.optional(), customSupportPrompts: customSupportPromptsSchema.optional(), enhancementApiConfigId: z.string().optional(), + marketplaceSources: z + .array( + z.object({ + url: z.string(), + name: z.string().optional(), + enabled: z.boolean(), + }), + ) + .optional(), historyPreviewCollapsed: z.boolean().optional(), }) @@ -937,6 +948,7 @@ const globalSettingsRecord: GlobalSettingsRecord = { customSupportPrompts: undefined, enhancementApiConfigId: undefined, cachedChromeHostUrl: undefined, + marketplaceSources: undefined, historyPreviewCollapsed: undefined, } diff --git a/src/services/marketplace/GitFetcher.ts b/src/services/marketplace/GitFetcher.ts new file mode 100644 index 0000000000..9680627944 --- /dev/null +++ b/src/services/marketplace/GitFetcher.ts @@ -0,0 +1,317 @@ +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import * as yaml from "js-yaml" +import simpleGit, { SimpleGit } from "simple-git" +import { MetadataScanner } from "./MetadataScanner" +import { validateAnyMetadata } from "./schemas" +import { LocalizationOptions, MarketplaceItem, MarketplaceRepository, RepositoryMetadata } from "./types" +import { getUserLocale } from "./utils" + +/** + * Handles fetching and caching marketplace repositories + */ +export class GitFetcher { + private readonly cacheDir: string + private metadataScanner: MetadataScanner + private git?: SimpleGit + private localizationOptions: LocalizationOptions + private activeGitInstances: Set = new Set() + + constructor(context: vscode.ExtensionContext, localizationOptions?: LocalizationOptions) { + this.cacheDir = path.join(context.globalStorageUri.fsPath, "marketplace-cache") + this.localizationOptions = localizationOptions || { + userLocale: getUserLocale(), + fallbackLocale: "en", + } + this.metadataScanner = new MetadataScanner(undefined, this.localizationOptions) + } + + /** + * Clean up resources + */ + dispose(): void { + // Clean up all git instances + this.activeGitInstances.forEach((git) => { + try { + // Force cleanup of git instance + ;(git as any)._executor = null + } catch { + // Ignore cleanup errors + } + }) + this.activeGitInstances.clear() + + // Clean up metadata scanner + if (this.metadataScanner) { + this.metadataScanner = null as any + } + } + + /** + * Initialize git instance for a repository + * @param repoDir Repository directory + */ + private initGit(repoDir: string): void { + // Clean up old git instance if it exists + if (this.git) { + this.activeGitInstances.delete(this.git) + try { + // Force cleanup of git instance + ;(this.git as any)._executor = null + } catch { + // Ignore cleanup errors + } + } + + // Create new git instance + this.git = simpleGit(repoDir) + this.activeGitInstances.add(this.git) + + // Update MetadataScanner with new git instance + const oldScanner = this.metadataScanner + this.metadataScanner = new MetadataScanner(this.git, this.localizationOptions) + + // Clean up old scanner + if (oldScanner) { + oldScanner.dispose?.() + } + } + + /** + * Fetch repository data + * @param repoUrl Repository URL + * @param forceRefresh Whether to bypass cache + * @param sourceName Optional source repository name + * @returns Repository data + */ + async fetchRepository(repoUrl: string, forceRefresh = false, sourceName?: string): Promise { + // Ensure cache directory exists + await fs.mkdir(this.cacheDir, { recursive: true }) + + // Get repository directory name from URL + const repoName = this.getRepositoryName(repoUrl) + const repoDir = path.join(this.cacheDir, repoName) + + // Clone or pull repository + await this.cloneOrPullRepository(repoUrl, repoDir, forceRefresh) + + // Initialize git for this repository + this.initGit(repoDir) + + // Find the registry dir + const registryDir = await this.findRegistryDir(repoDir) + + // Validate repository structure + await this.validateRegistryStructure(registryDir) + + // Parse repository metadata + const metadata = await this.parseRepositoryMetadata(registryDir) + + // Parse marketplace items + // Get current branch using existing git instance + const branch = (await this.git?.revparse(["--abbrev-ref", "HEAD"])) || "main" + + const items = await this.parseMarketplaceItems(registryDir, repoUrl, sourceName || metadata.name) + + return { + metadata, + items: items.map((item) => ({ ...item, defaultBranch: branch })), + url: repoUrl, + defaultBranch: branch, + } + } + + async findRegistryDir(repoDir: string) { + const isRoot = await fs + .stat(path.join(repoDir, "metadata.en.yml")) + .then(() => true) + .catch(() => false) + + if (isRoot) return repoDir + + const isRegistrySubdir = await fs + .stat(path.join(repoDir, "registry", "metadata.en.yml")) + .then(() => true) + .catch(() => false) + + if (isRegistrySubdir) return path.join(repoDir, "registry") + + throw new Error('Invalid repository structure: could not find "registry" metadata') + } + + /** + * Get repository name from URL + * @param repoUrl Repository URL + * @returns Repository name + */ + private getRepositoryName(repoUrl: string): string { + const match = repoUrl.match(/\/([^/]+?)(?:\.git)?$/) + if (!match) { + throw new Error(`Invalid repository URL: ${repoUrl}`) + } + return match[1] + } + + /** + * Clone or pull repository + * @param repoUrl Repository URL + * @param repoDir Repository directory + * @param forceRefresh Whether to force refresh + */ + /** + * Clean up any git lock files in the repository + * @param repoDir Repository directory + */ + private async cleanupGitLocks(repoDir: string): Promise { + const indexLockPath = path.join(repoDir, ".git", "index.lock") + try { + await fs.unlink(indexLockPath) + } catch { + // Ignore errors if file doesn't exist + } + } + + private async cloneOrPullRepository(repoUrl: string, repoDir: string, forceRefresh: boolean): Promise { + try { + // Clean up any existing git lock files first + await this.cleanupGitLocks(repoDir) + // Check if repository exists + const gitDir = path.join(repoDir, ".git") + let repoExists = await fs + .stat(gitDir) + .then(() => true) + .catch(() => false) + + if (repoExists && !forceRefresh) { + try { + // Pull latest changes + const git = simpleGit(repoDir) + // Force pull with overwrite + await git.fetch("origin", "main") + await git.raw(["reset", "--hard", "origin/main"]) + await git.raw(["clean", "-f", "-d"]) + } catch (error) { + // Clean up git locks before retrying + await this.cleanupGitLocks(repoDir) + // If pull fails with specific errors that indicate repo corruption, + // we should remove and re-clone + const errorMessage = error instanceof Error ? error.message : String(error) + if ( + errorMessage.includes("not a git repository") || + errorMessage.includes("repository not found") || + errorMessage.includes("refusing to merge unrelated histories") + ) { + await fs.rm(repoDir, { recursive: true, force: true }) + repoExists = false + } else { + throw error + } + } + } + + if (!repoExists || forceRefresh) { + try { + // Clean up any existing git lock files + const indexLockPath = path.join(repoDir, ".git", "index.lock") + try { + await fs.unlink(indexLockPath) + } catch { + // Ignore errors if file doesn't exist + } + + // Always remove the directory before cloning + await fs.rm(repoDir, { recursive: true, force: true }) + + // Add a small delay to ensure directory is fully cleaned up + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Verify directory is gone before proceeding + const dirExists = await fs + .stat(repoDir) + .then(() => true) + .catch(() => false) + if (dirExists) { + throw new Error("Failed to clean up directory before cloning") + } + + // Clone repository + const git = simpleGit() + // Clone with force options + await git.clone(repoUrl, repoDir) + // Reset to ensure clean state + const repoGit = simpleGit(repoDir) + await repoGit.raw(["clean", "-f", "-d"]) + await repoGit.raw(["reset", "--hard", "HEAD"]) + } catch (error) { + // If clone fails, ensure we clean up any partially created directory + try { + await fs.rm(repoDir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } + throw error + } + } + + // Get current branch using existing git instance + // const branch = + ;(await this.git?.revparse(["--abbrev-ref", "HEAD"])) || "main" + } catch (error) { + throw new Error( + `Failed to clone/pull repository: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Validate registry structure + * @param repoDir Registry directory + */ + private async validateRegistryStructure(repoDir: string): Promise { + // Check for metadata.en.yml + const metadataPath = path.join(repoDir, "metadata.en.yml") + try { + await fs.stat(metadataPath) + } catch { + throw new Error("Registry is missing metadata.en.yml file") + } + } + + /** + * Parse repository metadata + * @param repoDir Repository directory + * @returns Repository metadata + */ + private async parseRepositoryMetadata(repoDir: string): Promise { + const metadataPath = path.join(repoDir, "metadata.en.yml") + const metadataContent = await fs.readFile(metadataPath, "utf-8") + + try { + const parsed = yaml.load(metadataContent) as Record + return validateAnyMetadata(parsed) as RepositoryMetadata + } catch (error) { + console.error("Failed to parse repository metadata:", error) + return { + name: "Unknown Repository", + description: "Failed to load repository", + version: "0.0.0", + } + } + } + + /** + * Parse marketplace items + * @param repoDir Repository directory + * @param repoUrl Repository URL + * @param sourceName Source repository name + * @returns Array of marketplace items + */ + private async parseMarketplaceItems( + repoDir: string, + repoUrl: string, + sourceName: string, + ): Promise { + return this.metadataScanner.scanDirectory(repoDir, repoUrl, sourceName) + } +} diff --git a/src/services/marketplace/InstalledMetadataManager.ts b/src/services/marketplace/InstalledMetadataManager.ts new file mode 100644 index 0000000000..924a6166a3 --- /dev/null +++ b/src/services/marketplace/InstalledMetadataManager.ts @@ -0,0 +1,205 @@ +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import * as yaml from "js-yaml" +import { z } from "zod" +import { ensureSettingsDirectoryExists } from "../../utils/globalContext" + +const ItemInstalledMetadataSchema = z.object({ + version: z.string(), + modes: z.array(z.string()).optional(), + mcps: z.array(z.string()).optional(), + files: z.array(z.string()).optional(), +}) +export type ItemInstalledMetadata = z.infer + +const ScopeInstalledMetadataSchema = z.record(ItemInstalledMetadataSchema) +export type ScopeInstalledMetadata = z.infer + +// Full metadata structure +export interface FullInstallatedMetadata { + project: ScopeInstalledMetadata + global: ScopeInstalledMetadata +} + +/** + * Manages installed marketplace item metadata for both project and global scopes. + */ +export class InstalledMetadataManager { + public fullMetadata: FullInstallatedMetadata = { + project: {}, + global: {}, + } + + constructor(private readonly context: vscode.ExtensionContext) {} + + /** + * Loads and validates metadata from a YAML file at the given path. + * + * Returns an empty object if the file doesn't exist or is invalid. + * + * Throws errors for issues other than file not found or validation errors. + */ + private async loadMetadataFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + const data = yaml.load(content) + + const validationResult = ScopeInstalledMetadataSchema.safeParse(data) + + if (validationResult.success) { + return validationResult.data + } else { + console.warn( + `InstalledMetadataManager: Invalid metadata structure in ${filePath}. Validation errors:`, + validationResult.error.flatten(), + ) + return {} // Return empty for validation errors + } + } catch (error: any) { + if (error.code === "ENOENT") { + return {} // File not found is expected + } + + // Re-throw unexpected errors (e.g., permissions issues, YAML parsing errors) + console.error(`InstalledMetadataManager: Error reading or parsing metadata file ${filePath}:`, error) + throw error + } + } + + /** + * Reloads project-specific installed metadata from .roo/.marketplace/metadata.yml. + */ + async reloadProject(): Promise { + const metadataPath = await this.getMetadataFilePath("project") + if (!metadataPath) { + this.fullMetadata.project = {} + } else { + try { + this.fullMetadata.project = await this.loadMetadataFile(metadataPath) + console.debug("Project metadata reloaded:", this.fullMetadata.project) + } catch (error) { + console.error("InstalledMetadataManager: Failed to reload project metadata:", error) + this.fullMetadata.project = {} // Reset on load failure + } + } + return this.fullMetadata.project + } + + /** + * Reloads global installed metadata from the extension's global storage. + */ + async reloadGlobal(): Promise { + const metadataPath = await this.getMetadataFilePath("global") + if (!metadataPath) { + this.fullMetadata.global = {} + } else { + try { + this.fullMetadata.global = await this.loadMetadataFile(metadataPath) + console.debug("Global metadata reloaded:", this.fullMetadata.global) + } catch (error) { + console.error("InstalledMetadataManager: Failed to reload global metadata:", error) + this.fullMetadata.global = {} // Reset on load failure + } + } + return this.fullMetadata.global + } + + /** + * Gets the metadata for a specific installed item. + * @param scope The scope ('project' or 'global') + * @param itemId The ID of the item + * @returns The item's metadata or undefined if not found. + */ + getInstalledItem(scope: "project" | "global", itemId: string): ItemInstalledMetadata | undefined { + return this.fullMetadata[scope]?.[itemId] + } + + /** + * Gets the file path for the metadata file based on the scope. + * @param scope The scope ('project' or 'global') + * @returns The full file path or undefined if scope is project and no workspace is open. + */ + private async getMetadataFilePath(scope: "project" | "global"): Promise { + if (scope === "project") { + if (!vscode.workspace.workspaceFolders?.length) { + console.error("InstalledMetadataManager: Cannot get project metadata path, no workspace folder open.") + return undefined + } + const workspaceFolder = vscode.workspace.workspaceFolders[0].uri.fsPath + return path.join(workspaceFolder, ".roo", ".marketplace", "metadata.yml") + } else { + // Global scope + try { + const globalSettingsPath = await ensureSettingsDirectoryExists(this.context) + return path.join(globalSettingsPath, ".marketplace", "metadata.yml") + } catch (error) { + console.error("InstalledMetadataManager: Failed to get global settings directory path:", error) + return undefined + } + } + } + + /** + * Saves the metadata for a given scope to its corresponding YAML file. + * + * Throws an error if the file path cannot be determined or if saving fails. + * + * @param scope The scope ('project' or 'global') + * @param metadata The metadata object to save. + */ + private async saveMetadataFile(scope: "project" | "global", metadata: ScopeInstalledMetadata): Promise { + const filePath = await this.getMetadataFilePath(scope) + if (!filePath) { + throw new Error(`InstalledMetadataManager: Could not determine metadata file path for scope '${scope}'.`) + } + + try { + // Ensure the directory exists + await fs.mkdir(path.dirname(filePath), { recursive: true }) + + // Serialize metadata to YAML + const yamlContent = yaml.dump(metadata) + + // Write to file + await fs.writeFile(filePath, yamlContent, "utf-8") + console.debug(`InstalledMetadataManager: Metadata saved successfully to ${filePath}`) + } catch (error) { + console.error(`InstalledMetadataManager: Error saving metadata file ${filePath}:`, error) + throw error // Re-throw save errors + } + } + + /** + * Adds or updates metadata for an installed item and saves it. + * @param scope The scope ('project' or 'global') + * @param itemId The ID of the item + * @param details The metadata details of the item + */ + async addInstalledItem(scope: "project" | "global", itemId: string, details: ItemInstalledMetadata): Promise { + // Add/update the item + this.fullMetadata[scope][itemId] = details + + // Save the updated metadata for the entire scope + await this.saveMetadataFile(scope, this.fullMetadata[scope]) + console.log(`Installed item added/updated: ${scope}/${itemId}`) + } + + /** + * Removes metadata for an installed item and saves the changes. + * @param scope The scope ('project' or 'global') + * @param itemId The ID of the item + */ + async removeInstalledItem(scope: "project" | "global", itemId: string): Promise { + // Check if item exists + if (this.fullMetadata[scope]?.[itemId]) { + delete this.fullMetadata[scope][itemId] + + // Save the updated metadata + await this.saveMetadataFile(scope, this.fullMetadata[scope]) + console.log(`Installed item removed: ${scope}/${itemId}`) + } else { + console.warn(`InstalledMetadataManager: Item not found for removal: ${scope}/${itemId}`) + } + } +} diff --git a/src/services/marketplace/MarketplaceManager.ts b/src/services/marketplace/MarketplaceManager.ts new file mode 100644 index 0000000000..1dbed8e17e --- /dev/null +++ b/src/services/marketplace/MarketplaceManager.ts @@ -0,0 +1,784 @@ +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import { GitFetcher } from "./GitFetcher" +import { + MarketplaceItem, + MarketplaceRepository, + MarketplaceSource, + MarketplaceItemType, + ComponentMetadata, + LocalizationOptions, + InstallMarketplaceItemOptions, + RemoveInstalledMarketplaceItemOptions, +} from "./types" +import { getUserLocale } from "./utils" +import { GlobalFileNames } from "../../shared/globalFileNames" +import { assertsMpContext, createHookable, MarketplaceContext, registerMarketplaceHooks } from "roo-rocket" +import { assertsBinarySha256, unpackFromUint8, extractRocketConfigFromUint8 } from "config-rocket/cli" +import { getPanel } from "../../activate/registerCommands" +import { ensureSettingsDirectoryExists } from "../../utils/globalContext" +import { InstalledMetadataManager, ItemInstalledMetadata } from "./InstalledMetadataManager" + +/** + * Service for managing marketplace data + */ +export class MarketplaceManager { + private currentItems: MarketplaceItem[] = [] + private originalItems: MarketplaceItem[] = [] + private static readonly CACHE_EXPIRY_MS = 3600000 // 1 hour + + IMM: InstalledMetadataManager + + private gitFetcher: GitFetcher + private cache: Map = new Map() + public isFetching = false + + // Concurrency control + private activeSourceOperations = new Set() // Track active git operations per source + private isMetadataScanActive = false // Track active metadata scanning + private pendingOperations: Array<() => Promise> = [] // Queue for pending operations + + constructor(private readonly context: vscode.ExtensionContext) { + const localizationOptions: LocalizationOptions = { + userLocale: getUserLocale(), + fallbackLocale: "en", + } + this.gitFetcher = new GitFetcher(context, localizationOptions) + this.IMM = new InstalledMetadataManager(context) + // Initial loading for the metadatas + void this.IMM.reloadProject() + void this.IMM.reloadGlobal() + } + + /** + * Queue an operation to run when no metadata scan is active + */ + private async queueOperation(operation: () => Promise): Promise { + if (this.isMetadataScanActive) { + return new Promise((resolve) => { + this.pendingOperations.push(async () => { + await operation() + resolve() + }) + }) + } + + try { + this.isMetadataScanActive = true + await operation() + } finally { + this.isMetadataScanActive = false + + // Process any pending operations + const nextOperation = this.pendingOperations.shift() + if (nextOperation) { + void this.queueOperation(nextOperation) + } + } + } + + async getMarketplaceItems( + enabledSources: MarketplaceSource[], + ): Promise<{ items: MarketplaceItem[]; errors?: string[] }> { + const items: MarketplaceItem[] = [] + const errors: string[] = [] + + // Process sources sequentially with locking + for (const source of enabledSources) { + if (this.isSourceLocked(source.url)) { + continue + } + + try { + this.lockSource(source.url) + + // Queue metadata scanning operation + await this.queueOperation(async () => { + const repo = await this.getRepositoryData(source.url, false, source.name) + + if (repo.items && repo.items.length > 0) { + // Ensure each item is properly attributed to its source + const itemsWithSource = repo.items.map((item) => ({ + ...item, + sourceName: source.name || this.getRepoNameFromUrl(source.url), + sourceUrl: source.url, + })) + items.push(...itemsWithSource) + } + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`MarketplaceManager: Failed to fetch data from ${source.url}:`, error) + errors.push(`Source ${source.url}: ${errorMessage}`) + } finally { + this.unlockSource(source.url) + } + } + + // Store the current items + this.currentItems = items + // Preserve original unfiltered items + this.originalItems = items + + // Return both items and errors + const result = { + items, + ...(errors.length > 0 && { errors }), + } + + return result + } + + /** + * Check if a source operation is in progress + */ + private isSourceLocked(url: string): boolean { + return this.activeSourceOperations.has(url) + } + + /** + * Lock a source for operations + */ + private lockSource(url: string): void { + this.activeSourceOperations.add(url) + } + + /** + * Unlock a source after operations complete + */ + private unlockSource(url: string): void { + this.activeSourceOperations.delete(url) + } + + async getRepositoryData( + url: string, + forceRefresh: boolean = false, + sourceName?: string, + ): Promise { + try { + // Check cache first (unless force refresh is requested) + const cached = this.cache.get(url) + + if (!forceRefresh && cached && Date.now() - cached.timestamp < MarketplaceManager.CACHE_EXPIRY_MS) { + return cached.data + } + + // Fetch fresh data with timeout protection + const fetchPromise = this.gitFetcher.fetchRepository(url, forceRefresh, sourceName) + + // Create a timeout promise + let timeoutId: NodeJS.Timeout | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Repository fetch timed out after 30 seconds: ${url}`)) + }, 30000) // 30 second timeout + }) + + try { + // Race the fetch against the timeout + const result = await Promise.race([fetchPromise, timeoutPromise]) + + // Cache the result + this.cache.set(url, { data: result, timestamp: Date.now() }) + + return result + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + } + } catch (error) { + console.error(`MarketplaceManager: Error fetching repository data for ${url}:`, error) + + // Return empty repository data instead of throwing + return { + metadata: { + name: "Unknown Repository", + description: "Failed to load repository", + version: "0.0.0", + }, + items: [], + url, + } + } + } + + /** + * Refreshes a specific repository, bypassing the cache + * @param url The repository URL to refresh + * @param sourceName Optional name of the source + * @returns The refreshed repository data + */ + async refreshRepository(url: string, sourceName?: string): Promise { + try { + // Force a refresh by bypassing the cache + const data = await this.getRepositoryData(url, true, sourceName) + return data + } catch (error) { + console.error(`MarketplaceManager: Failed to refresh repository ${url}:`, error) + return { + metadata: { + name: "Unknown Repository", + description: "Failed to load repository", + version: "0.0.0", + }, + items: [], + url, + error: error instanceof Error ? error.message : String(error), + } + } + } + + /** + * Clears the in-memory cache + */ + clearCache(): void { + this.cache.clear() + } + + /** + * Cleans up cache directories for repositories that are no longer in the configured sources + * @param currentSources The current list of marketplace sources + */ + async cleanupCacheDirectories(currentSources: MarketplaceSource[]): Promise { + try { + // Get the cache directory path + const cacheDir = path.join(this.context.globalStorageUri.fsPath, "marketplace-cache") + + // Check if cache directory exists + try { + await fs.stat(cacheDir) + } catch (error) { + return + } + + // Get all subdirectories in the cache directory + const entries = await fs.readdir(cacheDir, { withFileTypes: true }) + const cachedRepoDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + + // Get the list of repository names from current sources + const currentRepoNames = currentSources.map((source) => this.getRepoNameFromUrl(source.url)) + + // Find directories to delete + const dirsToDelete = cachedRepoDirs.filter((dir) => !currentRepoNames.includes(dir)) + + // Delete each directory that's no longer in the sources + for (const dirName of dirsToDelete) { + try { + const dirPath = path.join(cacheDir, dirName) + await fs.rm(dirPath, { recursive: true, force: true }) + } catch (error) { + console.error(`MarketplaceManager: Failed to delete directory ${dirName}:`, error) + } + } + } catch (error) { + console.error("MarketplaceManager: Error cleaning up cache directories:", error) + } + } + + /** + * Extracts a safe directory name from a Git URL + * @param url The Git repository URL + * @returns A sanitized directory name + */ + private getRepoNameFromUrl(url: string): string { + // Extract repo name from URL and sanitize it + const urlParts = url.split("/").filter((part) => part !== "") + const repoName = urlParts[urlParts.length - 1].replace(/\.git$/, "") + return repoName.replace(/[^a-zA-Z0-9-_]/g, "-") + } + + /** + * Filters marketplace items based on criteria + * @param items The items to filter + * @param filters The filter criteria + * @returns Filtered items + */ + private static readonly MAX_CACHE_SIZE = 100 + private static readonly BATCH_SIZE = 100 + + private filterCache = new Map< + string, + { + items: MarketplaceItem[] + timestamp: number + } + >() + + /** + * Clear old entries from the filter cache + */ + private cleanupFilterCache(): void { + if (this.filterCache.size > MarketplaceManager.MAX_CACHE_SIZE) { + // Sort by timestamp and keep only the most recent entries + const entries = Array.from(this.filterCache.entries()) + .sort(([, a], [, b]) => b.timestamp - a.timestamp) + .slice(0, MarketplaceManager.MAX_CACHE_SIZE) + + this.filterCache.clear() + entries.forEach(([key, value]) => this.filterCache.set(key, value)) + } + } + + /** + * Filter items + */ + filterItems( + items: MarketplaceItem[], + filters: { type?: MarketplaceItemType; search?: string; tags?: string[] }, + ): MarketplaceItem[] { + // Create cache key from filters + const cacheKey = JSON.stringify(filters) + const cached = this.filterCache.get(cacheKey) + if (cached) { + return cached.items + } + + // Clean up old cache entries + this.cleanupFilterCache() + + // Process items in batches to avoid memory spikes + const allFilteredItems: MarketplaceItem[] = [] + for (let i = 0; i < items.length; i += MarketplaceManager.BATCH_SIZE) { + const batch = items.slice(i, Math.min(i + MarketplaceManager.BATCH_SIZE, items.length)) + const filteredBatch = this.processItemBatch(batch, filters) + allFilteredItems.push(...filteredBatch) + } + + // Cache the results + this.filterCache.set(cacheKey, { + items: allFilteredItems, + timestamp: Date.now(), + }) + + return allFilteredItems + } + + /** + * Process a batch of items + */ + private processItemBatch( + batch: MarketplaceItem[], + filters: { type?: MarketplaceItemType; search?: string; tags?: string[] }, + ): MarketplaceItem[] { + // Helper functions + const normalizeText = (text: string) => text.toLowerCase().replace(/\s+/g, " ").trim() + const searchTerm = filters.search ? normalizeText(filters.search) : "" + const containsSearchTerm = (text: string) => !searchTerm || normalizeText(text).includes(searchTerm) + + return batch + .map((item) => { + const itemCopy = { ...item } + + // Check parent item matches + const itemMatches = { + type: !filters.type || itemCopy.type === filters.type, + search: + !searchTerm || containsSearchTerm(itemCopy.name) || containsSearchTerm(itemCopy.description), + tags: + !filters.tags?.length || + (itemCopy.tags && filters.tags.some((tag) => itemCopy.tags!.includes(tag))), + } + + // Process subcomponents + let hasMatchingSubcomponents = false + if (itemCopy.items?.length) { + itemCopy.items = itemCopy.items.map((subItem) => { + const subMatches = { + type: !filters.type || subItem.type === filters.type, + search: + !searchTerm || + (subItem.metadata && + (containsSearchTerm(subItem.metadata.name || "") || + containsSearchTerm(subItem.metadata.description || "") || + containsSearchTerm(subItem.type || ""))), + tags: + !filters.tags?.length || + (subItem.metadata?.tags && + filters.tags.some((tag) => subItem.metadata!.tags!.includes(tag))), + } + + const subItemMatched = + subMatches.type && + (!searchTerm || subMatches.search) && + (!filters.tags?.length || subMatches.tags) + + if (subItemMatched) { + hasMatchingSubcomponents = true + const matchReason: Record = { + nameMatch: searchTerm ? containsSearchTerm(subItem.metadata?.name || "") : true, + descriptionMatch: searchTerm + ? containsSearchTerm(subItem.metadata?.description || "") + : false, + } + + if (filters.type) { + matchReason.typeMatch = subMatches.type + } + + subItem.matchInfo = { + matched: true, + matchReason, + } + } else { + subItem.matchInfo = { matched: false } + } + + return subItem + }) + } + + const hasActiveFilters = filters.type || searchTerm || filters.tags?.length + if (!hasActiveFilters) return itemCopy + + const parentMatchesAll = itemMatches.type && itemMatches.search && itemMatches.tags + const isPackageWithMatchingSubcomponent = itemCopy.type === "package" && hasMatchingSubcomponents + + if (parentMatchesAll || isPackageWithMatchingSubcomponent) { + const matchReason: Record = { + nameMatch: searchTerm ? containsSearchTerm(itemCopy.name) : false, + descriptionMatch: searchTerm ? containsSearchTerm(itemCopy.description) : false, + } + + if (filters.type) { + matchReason.typeMatch = itemMatches.type + } + + if (hasMatchingSubcomponents) { + matchReason.hasMatchingSubcomponents = true + } + + // If this is a package and we're searching, also check if any subcomponent names match + if (searchTerm && itemCopy.type === "package" && itemCopy.items?.length) { + const subcomponentNameMatches = itemCopy.items.some( + (subItem) => subItem.metadata && containsSearchTerm(subItem.metadata.name || ""), + ) + if (subcomponentNameMatches) { + matchReason.hasMatchingSubcomponents = true + } + } + + itemCopy.matchInfo = { + matched: true, + matchReason, + } + return itemCopy + } + + return null + }) + .filter((item): item is MarketplaceItem => item !== null) + } + + /** + * Sorts marketplace items + * @param items The items to sort + * @param sortBy The field to sort by + * @param sortOrder The sort order + * @returns Sorted items + */ + sortItems( + items: MarketplaceItem[], + sortBy: keyof Pick, + sortOrder: "asc" | "desc", + sortSubcomponents: boolean = false, + ): MarketplaceItem[] { + return [...items] + .map((item) => { + // Deep clone the item + const clonedItem = { ...item } + + // Sort or preserve subcomponents + if (clonedItem.items && clonedItem.items.length > 0) { + clonedItem.items = [...clonedItem.items] + if (sortSubcomponents) { + clonedItem.items.sort((a, b) => { + const aValue = this.getSortValue(a, sortBy) + const bValue = this.getSortValue(b, sortBy) + const comparison = aValue.localeCompare(bValue) + return sortOrder === "asc" ? comparison : -comparison + }) + } + } + + return clonedItem + }) + .sort((a, b) => { + const aValue = this.getSortValue(a, sortBy) + const bValue = this.getSortValue(b, sortBy) + const comparison = aValue.localeCompare(bValue) + return sortOrder === "asc" ? comparison : -comparison + }) + } + + /** + * Gets the current marketplace items + * @returns The current items + */ + getCurrentItems(): MarketplaceItem[] { + return this.currentItems + } + + /** + * Updates current items with filtered results + * @param filters The filter criteria + * @returns Filtered items + */ + updateWithFilteredItems(filters: { + type?: MarketplaceItemType + search?: string + tags?: string[] + }): MarketplaceItem[] { + // If no filters, restore full list + if (!filters.type && !filters.search && (!filters.tags || filters.tags.length === 0)) { + this.currentItems = this.originalItems + return this.currentItems + } + // Filter based on original items + const filteredItems = this.filterItems(this.originalItems, filters) + this.currentItems = filteredItems + return filteredItems + } + + /** + * Cleans up resources used by the marketplace + */ + async cleanup(): Promise { + // Clean up cache directories for all sources + const sources = Array.from(this.cache.keys()).map((url) => ({ url, enabled: true })) + await this.cleanupCacheDirectories(sources) + this.clearCache() + // Clear filter cache + this.filterCache.clear() + } + + /** + * Helper method to get the sort value for an item + */ + private getSortValue( + item: + | MarketplaceItem + | { type: MarketplaceItemType; path: string; metadata?: ComponentMetadata; lastUpdated?: string }, + sortBy: keyof Pick, + ): string { + if ("metadata" in item && item.metadata) { + // Handle subcomponent + switch (sortBy) { + case "name": + return item.metadata.name + case "author": + return "" + case "lastUpdated": + return item.lastUpdated || "" + default: + return item.metadata.name + } + } else { + // Handle parent item + const parentItem = item as MarketplaceItem + switch (sortBy) { + case "name": + return parentItem.name + case "author": + return parentItem.author || "" + case "lastUpdated": + return parentItem.lastUpdated || "" + default: + return parentItem.name + } + } + } + + /** + * Resolves the cwd for the specified target scope + */ + async resolveScopeCwd(target: InstallMarketplaceItemOptions["target"]): Promise { + if (target === "project" && !vscode.workspace.workspaceFolders?.length) + throw new Error("Cannot load current workspace folder") + + return target === "project" + ? vscode.workspace.workspaceFolders![0].uri.fsPath + : await ensureSettingsDirectoryExists(this.context) + } + + async installMarketplaceItem( + item: MarketplaceItem, + options?: InstallMarketplaceItemOptions, + ): Promise<"$COMMIT" | any> { + // Temporary added due to hosting with _doInstall + const _IMM = this.IMM + + const { target = "project", parameters } = options || {} + + vscode.window.showInformationMessage(`Installing item: "${item.name}"`) + + const cwd = await this.resolveScopeCwd(target) + + if (!item.binaryUrl || !item.binaryHash) throw new Error("Item does not have a binary URL or hash.") + + // Creates `mpContext` to delegate context to `roo-rocket` + const mpContext: MarketplaceContext = + target === "project" + ? { target } + : { + target, + globalFileNames: { + mcp: GlobalFileNames.mcpSettings, + mode: GlobalFileNames.customModes, + }, + } + assertsMpContext(mpContext) + + // Fetch the binary + const binaryUint8 = await fetchBinary(item.binaryUrl) + + // `parameters` only exists in flows where we already check everything and then requires parameters input + // so we can optimize and skip the latter checks + if (parameters) return await _doInstall() + + // Check binary integrity + await assertsBinarySha256(binaryUint8, item.binaryHash) + + // Extract config and check if it has prompt parameters. + const config = await extractRocketConfigFromUint8(binaryUint8) + const configHavePromptParameters = config?.parameters?.some((param) => param.resolver.operation === "prompt") + if (configHavePromptParameters) { + vscode.window.showInformationMessage(`"${item.name}" is configurable, opening UI form...`) + + const panel = getPanel() + if (panel) { + panel.webview.postMessage({ + type: "openMarketplaceInstallSidebarWithConfig", + payload: { + item, + config, + }, + }) + } else { + throw new Error("Could not open UI form: Webview panel not found.") + } + return false // Stop installation process here, wait for parameters from frontend + } + + return await _doInstall() + async function _doInstall() { + // Create a custom hookable instance to support global installations + const customHookable = createHookable() + registerMarketplaceHooks(customHookable, mpContext) + + // Register hook to set parameters if provided + if (parameters) + customHookable.hook("onParameter", ({ parameter, resolvedParameters }) => { + if (parameter.id in parameters) + return (resolvedParameters[parameter.id] = parameters[parameter.id as keyof typeof parameters]) + + // If there is unresolved prompt operation, throw error or else it would hang the installation + if (parameter.resolver.operation === "prompt") throw new Error("Unexpected prompt operation") + }) + + // Register hooks to build `ItemInstalledMetadata` + const itemInstalledMetadata: ItemInstalledMetadata = { + version: item.version, + modes: [], + mcps: [], + files: [], + } + customHookable.hook("onFileOutput", ({ filePath, data }) => { + if (filePath.endsWith("/.roomodes")) { + const parsedData = JSON.parse(data) + if (parsedData?.customModes?.length) { + parsedData.customModes.forEach((mode: any) => { + itemInstalledMetadata.modes?.push(mode.slug) + }) + } + } else if (filePath.endsWith("/.roo/mcp.json")) { + const parsedData = JSON.parse(data) + const mcpSlugs = Object.keys(parsedData?.mcpServers ?? {}) + if (mcpSlugs.length) { + mcpSlugs.forEach((mcpSlug: any) => { + itemInstalledMetadata.mcps?.push(mcpSlug) + }) + } + } else { + itemInstalledMetadata.files?.push(path.relative(cwd, filePath)) + } + }) + + vscode.window.showInformationMessage(`"${item.name}" is unpacking...`) + await unpackFromUint8(binaryUint8, { + hookable: customHookable, + nonAssemblyBehavior: true, + cwd, + }).then(() => { + _IMM.addInstalledItem("project", item.id, itemInstalledMetadata) + }) + vscode.window.showInformationMessage(`"${item.name}" installed successfully`) + + return "$COMMIT" + } + } + + async removeInstalledMarketplaceItem( + item: MarketplaceItem, + options?: RemoveInstalledMarketplaceItemOptions, + ): Promise<"$COMMIT" | any> { + const { target = "project" } = options || {} + + vscode.window.showInformationMessage(`Removing item: "${item.name}"`) + + const cwd = await this.resolveScopeCwd(target) + const modesFilePath = path.join(cwd, target === "project" ? ".roomodes" : GlobalFileNames.customModes) + const mcpsFilePath = path.join(cwd, target === "project" ? ".roo/mcp.json" : GlobalFileNames.mcpSettings) + + const itemInstalledMetadata = this.IMM.getInstalledItem(target, item.id) + if (itemInstalledMetadata) { + if (itemInstalledMetadata.modes) { + if (await fs.access(modesFilePath).catch(() => true)) + vscode.window.showWarningMessage(`"${item.name}": modes file not found`) + else { + const parsedModesFile = JSON.parse(await fs.readFile(modesFilePath, "utf-8")) + parsedModesFile.customModes = parsedModesFile.customModes.filter( + (m: any) => !itemInstalledMetadata.modes!.includes(m.slug), + ) + await fs.writeFile(modesFilePath, JSON.stringify(parsedModesFile, null, 2), "utf-8") + } + } + if (itemInstalledMetadata.mcps) { + if (await fs.access(mcpsFilePath).catch(() => true)) + vscode.window.showWarningMessage(`"${item.name}": mcps file not found`) + else { + const parsedMcpsFile = JSON.parse(await fs.readFile(mcpsFilePath, "utf-8")) + itemInstalledMetadata.mcps.forEach((mcp) => { + delete parsedMcpsFile.mcpServers[mcp] + }) + await fs.writeFile(mcpsFilePath, JSON.stringify(parsedMcpsFile, null, 2), "utf-8") + } + } + if (itemInstalledMetadata.files) { + for (const file of itemInstalledMetadata.files) { + try { + await fs.rm(path.join(cwd, file)) + } catch (error) { + vscode.window.showWarningMessage( + `"${item.name}": failed to remove file "${file}": ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + + this.IMM.removeInstalledItem(target, item.id) + vscode.window.showInformationMessage(`"${item.name}" removed successfully`) + return "$COMMIT" + } else { + throw new Error(`is not installed in scope "${target}"`) + } + } +} + +async function fetchBinary(url: string) { + const res = await fetch(url) + if (!res.ok) throw new Error(`Failed to download binary from ${url}`) + + return new Uint8Array(await res.arrayBuffer()) +} diff --git a/src/services/marketplace/MetadataScanner.ts b/src/services/marketplace/MetadataScanner.ts new file mode 100644 index 0000000000..890d25fc10 --- /dev/null +++ b/src/services/marketplace/MetadataScanner.ts @@ -0,0 +1,409 @@ +import * as path from "path" +import * as fs from "fs/promises" +import * as vscode from "vscode" +import * as yaml from "js-yaml" +import { SimpleGit } from "simple-git" +import { validateAnyMetadata } from "./schemas" +import { + ComponentMetadata, + MarketplaceItemType, + LocalizationOptions, + LocalizedMetadata, + MarketplaceItem, + PackageMetadata, +} from "./types" +import { getUserLocale } from "./utils" + +/** + * Handles component discovery and metadata loading + */ +export class MetadataScanner { + private git?: SimpleGit + private localizationOptions: LocalizationOptions + private originalRootDir: string | null = null + private static readonly MAX_DEPTH = 5 // Maximum directory depth + private static readonly BATCH_SIZE = 50 // Number of items to process at once + private static readonly CONCURRENT_SCANS = 3 // Number of concurrent directory scans + private isDisposed = false + + constructor(git?: SimpleGit, localizationOptions?: LocalizationOptions) { + this.git = git + this.localizationOptions = localizationOptions || { + userLocale: getUserLocale(), + fallbackLocale: "en", + } + } + + /** + * Clean up resources + */ + dispose(): void { + if (this.isDisposed) { + return + } + + // Clean up git instance reference + this.git = undefined + + // Clear any other references + this.originalRootDir = null + this.localizationOptions = null as any + + this.isDisposed = true + } + + /** + * Generator function to yield items in batches + */ + private async *scanDirectoryBatched( + rootDir: string, + repoUrl: string, + sourceName?: string, + depth: number = 0, + ): AsyncGenerator { + if (depth > MetadataScanner.MAX_DEPTH) { + return + } + + const batch: MarketplaceItem[] = [] + const entries = await fs.readdir(rootDir, { withFileTypes: true }) + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + const componentDir = path.join(rootDir, entry.name) + const metadata = await this.loadComponentMetadata(componentDir) + const localizedMetadata = metadata ? this.getLocalizedMetadata(metadata) : null + + if (localizedMetadata) { + const item = await this.createMarketplaceItem( + localizedMetadata, + componentDir, + repoUrl, + this.originalRootDir || rootDir, + sourceName, + ) + + if (item) { + // If this is a package, scan for subcomponents + if (this.isPackageMetadata(localizedMetadata)) { + await this.scanPackageSubcomponents(componentDir, item) + } + + batch.push(item) + if (batch.length >= MetadataScanner.BATCH_SIZE) { + yield batch.splice(0) + } + } + } + + // Only scan subdirectories if no metadata was found + if (!localizedMetadata) { + const subGenerator = this.scanDirectoryBatched(componentDir, repoUrl, sourceName, depth + 1) + for await (const subBatch of subGenerator) { + batch.push(...subBatch) + if (batch.length >= MetadataScanner.BATCH_SIZE) { + yield batch.splice(0) + } + } + } + } + + if (batch.length > 0) { + yield batch + } + } + + /** + * Scans a directory for components + * @param rootDir The root directory to scan + * @param repoUrl The repository URL + * @param sourceName Optional source repository name + * @returns Array of discovered items + */ + /** + * Scan a directory and return items in batches + */ + async scanDirectory( + rootDir: string, + repoUrl: string, + sourceName?: string, + isRecursiveCall: boolean = false, + ): Promise { + // Only set originalRootDir on the first call + if (!isRecursiveCall && !this.originalRootDir) { + this.originalRootDir = rootDir + } + + const items: MarketplaceItem[] = [] + const generator = this.scanDirectoryBatched(rootDir, repoUrl, sourceName) + + for await (const batch of generator) { + items.push(...batch) + } + + return items + } + + /** + * Gets localized metadata with fallback + * @param metadata The localized metadata object + * @returns The metadata in the user's locale or fallback locale, or null if neither is available + */ + private getLocalizedMetadata(metadata: LocalizedMetadata): ComponentMetadata | null { + const { userLocale, fallbackLocale } = this.localizationOptions + + // First try user's locale + if (metadata[userLocale]) { + return metadata[userLocale] + } + + // Fall back to fallbackLocale (typically English) + if (metadata[fallbackLocale]) { + return metadata[fallbackLocale] + } + + // No suitable metadata found + return null + } + + /** + * Loads metadata for a component + * @param componentDir The component directory + * @returns Localized metadata or null if no metadata found + */ + private async loadComponentMetadata(componentDir: string): Promise | null> { + const metadata: LocalizedMetadata = {} + try { + const entries = await fs.readdir(componentDir, { withFileTypes: true }) + + // Look for metadata.{locale}.yml files + for (const entry of entries) { + if (!entry.isFile()) continue + + const match = entry.name.match(/^metadata\.([a-z]{2})\.yml$/) + if (!match) continue + + const locale = match[1] + const metadataPath = path.join(componentDir, entry.name) + + try { + const content = await fs.readFile(metadataPath, "utf-8") + const parsed = yaml.load(content) as Record + + // Add type field if missing but has a parent directory indicating type + if (!parsed.type) { + const parentDir = path.basename(componentDir) + if (parentDir === "mcps") { + parsed.type = "mcp" + } + } + + metadata[locale] = validateAnyMetadata(parsed) as ComponentMetadata + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`Error loading metadata from ${metadataPath}:`, error) + + // Show validation errors to user + if (errorMessage.includes("Invalid metadata:")) { + vscode.window.showErrorMessage( + `Invalid metadata in ${path.basename(metadataPath)}: ${errorMessage.replace("Invalid metadata:", "").trim()}`, + ) + } + } + } + } catch (error) { + console.error(`Error reading directory ${componentDir}:`, error) + } + + return Object.keys(metadata).length > 0 ? metadata : null + } + + /** + * Creates a MarketplaceItem from component metadata + * @param metadata The component metadata + * @param componentDir The component directory + * @param repoUrl The repository URL + * @param sourceName Optional source repository name + * @returns MarketplaceItem or null if invalid + */ + private async createMarketplaceItem( + metadata: ComponentMetadata, + componentDir: string, + repoUrl: string, + rootDir: string, + sourceName?: string, + ): Promise { + // Skip if no type or invalid type + if (!metadata.type || !this.isValidMarketplaceItemType(metadata.type)) { + return null + } + // Always use the original root directory for path calculations + const effectiveRootDir = this.originalRootDir || rootDir + // Always calculate path relative to the original root directory + const relativePath = path.relative(effectiveRootDir, componentDir).replace(/\\/g, "/") + // Don't encode spaces in URL to match test expectations + const urlPath = relativePath + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/") + // Create the item with the correct path and URL + return { + id: metadata.id || `${metadata.type}#${relativePath || metadata.name}`, + name: metadata.name, + description: metadata.description, + type: metadata.type, + version: metadata.version, + binaryUrl: metadata.binaryUrl, + binaryHash: metadata.binaryHash, + tags: metadata.tags, + url: `${repoUrl}/tree/main/${urlPath}`, + repoUrl, + sourceName, + path: relativePath, + lastUpdated: await this.getLastModifiedDate(componentDir), + items: [], // Initialize empty items array for all components + author: metadata.author, + authorUrl: metadata.authorUrl, + sourceUrl: metadata.sourceUrl, + } + } + + /** + * Gets the last modified date for a component using git history + * @param componentDir The component directory + * @returns ISO date string + */ + private async getLastModifiedDate(componentDir: string): Promise { + if (this.git) { + try { + // Get the latest commit date for the directory and its contents + const result = await this.git.raw([ + "log", + "-1", + "--format=%aI", // ISO 8601 format + "--", + componentDir, + ]) + if (result) { + return result.trim() + } + } catch (error) { + console.error(`Error getting git history for ${componentDir}:`, error) + // Fall through to fs.stat fallback + } + } + + // Fallback to fs.stat if git is not available or fails + try { + const stats = await fs.stat(componentDir) + return stats.mtime.toISOString() + } catch { + return new Date().toISOString() + } + } + + /** + * Recursively scans a package directory for subcomponents + * @param packageDir The package directory to scan + * @param packageItem The package item to add subcomponents to + */ + private async scanPackageSubcomponents( + packageDir: string, + packageItem: MarketplaceItem, + parentPath: string = "", + ): Promise { + try { + // First check for explicitly listed items in package metadata + const metadataPath = path.join(packageDir, "metadata.en.yml") + try { + const content = await fs.readFile(metadataPath, "utf-8") + const parsed = yaml.load(content) as PackageMetadata + + if (parsed.items) { + for (const item of parsed.items) { + // For relative paths starting with ../, resolve from package directory + const itemPath = path.join(packageDir, item.path) + const subMetadata = await this.loadComponentMetadata(itemPath) + if (subMetadata) { + const localizedSubMetadata = this.getLocalizedMetadata(subMetadata) + if (localizedSubMetadata) { + packageItem.items = packageItem.items || [] + packageItem.items.push({ + type: localizedSubMetadata.type, + path: item.path, + metadata: localizedSubMetadata, + lastUpdated: await this.getLastModifiedDate(itemPath), + }) + } + } + } + } + } catch (error) { + // Ignore errors reading metadata.en.yml - we'll still scan subdirectories + } + + // Then scan subdirectories for implicit components + const entries = await fs.readdir(packageDir, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory()) continue + + const subPath = path.join(packageDir, entry.name) + const relativePath = parentPath ? `${parentPath}/${entry.name}` : entry.name + + // Try to load metadata directly + const subMetadata = await this.loadComponentMetadata(subPath) + if (!subMetadata) { + // If no metadata found, recurse into directory + await this.scanPackageSubcomponents(subPath, packageItem, relativePath) + continue + } + + // Get localized metadata with fallback + const localizedSubMetadata = this.getLocalizedMetadata(subMetadata) + if (!localizedSubMetadata) { + // If no localized metadata, recurse into directory + await this.scanPackageSubcomponents(subPath, packageItem, relativePath) + continue + } + + // Check if this component is already listed + const isListed = packageItem.items?.some((i) => i.path === relativePath) + if (!isListed) { + // Initialize items array if needed + packageItem.items = packageItem.items || [] + + // Add new subcomponent + packageItem.items.push({ + type: localizedSubMetadata.type, + path: relativePath, + metadata: localizedSubMetadata, + lastUpdated: await this.getLastModifiedDate(subPath), + }) + } + + // Don't recurse into directories that have valid metadata + } + } catch (error) { + console.error(`Error scanning package subcomponents in ${packageDir}:`, error) + } + } + + /** + * Type guard for component types + * @param type The type to check + * @returns Whether the type is valid + */ + private isValidMarketplaceItemType(type: string): type is MarketplaceItemType { + return ["role", "mcp", "storage", "mode", "prompt", "package"].includes(type) + } + + /** + * Type guard for package metadata + * @param metadata The metadata to check + * @returns Whether the metadata is for a package + */ + private isPackageMetadata(metadata: ComponentMetadata): metadata is PackageMetadata { + return metadata.type === "package" + } +} diff --git a/src/services/marketplace/__tests__/GitFetcher.test.ts b/src/services/marketplace/__tests__/GitFetcher.test.ts new file mode 100644 index 0000000000..e49f225595 --- /dev/null +++ b/src/services/marketplace/__tests__/GitFetcher.test.ts @@ -0,0 +1,378 @@ +import * as vscode from "vscode" +import { GitFetcher } from "../GitFetcher" +import * as fs from "fs/promises" +import * as path from "path" +import simpleGit, { SimpleGit } from "simple-git" + +// Mock simpleGit +jest.mock("simple-git", () => { + const mockGit = { + clone: jest.fn(), + pull: jest.fn(), + revparse: jest.fn(), + fetch: jest.fn(), + clean: jest.fn(), + raw: jest.fn(), + } + return jest.fn(() => mockGit) +}) + +// Mock fs/promises +jest.mock("fs/promises", () => ({ + mkdir: jest.fn(), + stat: jest.fn(), + rm: jest.fn(), + unlink: jest.fn(), + readdir: jest.fn().mockResolvedValue([]), + readFile: jest.fn().mockResolvedValue(` +name: Test Repository +description: Test Description +version: 1.0.0 +`), +})) + +// Mock child_process.exec for path with spaces tests +jest.mock("child_process", () => ({ + exec: jest.fn(), +})) + +// Mock promisify +jest.mock("util", () => ({ + promisify: jest.fn(), +})) + +// Mock vscode +const mockContext = { + globalStorageUri: { + fsPath: path.join(process.cwd(), "mock-storage-path"), + }, +} as vscode.ExtensionContext + +// Create mock Dirent objects +// const createMockDirent = (name: string, isDir: boolean): Dirent => { +// return { +// name, +// isDirectory: () => isDir, +// isFile: () => !isDir, +// isBlockDevice: () => false, +// isCharacterDevice: () => false, +// isFIFO: () => false, +// isSocket: () => false, +// isSymbolicLink: () => false, +// // These are readonly in the real Dirent +// path: "", +// parentPath: "", +// } as Dirent +// } + +describe("GitFetcher", () => { + let gitFetcher: GitFetcher + const mockSimpleGit = simpleGit as jest.MockedFunction + const testRepoUrl = "https://github.com/test/repo" + const testRepoDir = path.join(mockContext.globalStorageUri.fsPath, "marketplace-cache", "repo") + + beforeEach(() => { + jest.clearAllMocks() + gitFetcher = new GitFetcher(mockContext) + + // Reset fs mock defaults + ;(fs.mkdir as jest.Mock).mockResolvedValue(undefined) + ;(fs.rm as jest.Mock).mockImplementation((pathToRemove: string, options?: any) => { + // Always require recursive and force options + if (!options?.recursive || !options?.force) { + return Promise.reject(new Error("Invalid rm call: missing recursive or force options")) + } + // Allow any path under marketplace-cache directory + const normalizedPath = path.normalize(pathToRemove) + const normalizedCachePath = path.normalize( + path.join(mockContext.globalStorageUri.fsPath, "marketplace-cache"), + ) + if (normalizedPath.startsWith(normalizedCachePath)) { + return Promise.resolve(undefined) + } + return Promise.reject(new Error(`Invalid rm call: path ${pathToRemove} not in marketplace-cache`)) + }) + + // Setup fs.stat mock for repository structure validation + ;(fs.stat as jest.Mock).mockImplementation((path: string) => { + if (path.endsWith(".git")) return Promise.reject(new Error("ENOENT")) + if (path.endsWith("metadata.en.yml")) return Promise.resolve(true) + if (path.endsWith("README.md")) return Promise.resolve(true) + return Promise.reject(new Error("ENOENT")) + }) + + // Setup default git mock behavior + const mockGit = { + clone: jest.fn().mockResolvedValue(undefined), + pull: jest.fn().mockResolvedValue(undefined), + revparse: jest.fn().mockResolvedValue("main"), + // Add other required SimpleGit methods with no-op implementations + addAnnotatedTag: jest.fn(), + addConfig: jest.fn(), + applyPatch: jest.fn(), + listConfig: jest.fn(), + addRemote: jest.fn(), + addTag: jest.fn(), + branch: jest.fn(), + branchLocal: jest.fn(), + checkout: jest.fn(), + checkoutBranch: jest.fn(), + checkoutLatestTag: jest.fn(), + checkoutLocalBranch: jest.fn(), + clean: jest.fn(), + clearQueue: jest.fn(), + commit: jest.fn(), + cwd: jest.fn(), + deleteLocalBranch: jest.fn(), + deleteLocalBranches: jest.fn(), + diff: jest.fn(), + diffSummary: jest.fn(), + exec: jest.fn(), + fetch: jest.fn(), + getRemotes: jest.fn(), + init: jest.fn(), + log: jest.fn(), + merge: jest.fn(), + mirror: jest.fn(), + push: jest.fn(), + pushTags: jest.fn(), + raw: jest.fn(), + rebase: jest.fn(), + remote: jest.fn(), + removeRemote: jest.fn(), + reset: jest.fn(), + revert: jest.fn(), + show: jest.fn(), + stash: jest.fn(), + status: jest.fn(), + subModule: jest.fn(), + tag: jest.fn(), + tags: jest.fn(), + updateServerInfo: jest.fn(), + } as unknown as SimpleGit + mockSimpleGit.mockReturnValue(mockGit) + }) + + describe("fetchRepository", () => { + it("should successfully clone a new repository", async () => { + await expect(gitFetcher.fetchRepository(testRepoUrl)).resolves.toBeDefined() + + const mockGit = mockSimpleGit() + expect(mockGit.clone).toHaveBeenCalledWith(testRepoUrl, testRepoDir) + expect(mockGit.raw).toHaveBeenCalledWith(["clean", "-f", "-d"]) + expect(mockGit.raw).toHaveBeenCalledWith(["reset", "--hard", "HEAD"]) + }) + + it("should pull existing repository", async () => { + // Mock repository exists + ;(fs.stat as jest.Mock).mockImplementation((path: string) => { + if (path.endsWith(".git")) return Promise.resolve(true) + if (path.endsWith("metadata.en.yml")) return Promise.resolve(true) + if (path.endsWith("README.md")) return Promise.resolve(true) + return Promise.reject(new Error("ENOENT")) + }) + + await gitFetcher.fetchRepository(testRepoUrl) + + const mockGit = mockSimpleGit() + expect(mockGit.fetch).toHaveBeenCalledWith("origin", "main") + expect(mockGit.raw).toHaveBeenCalledWith(["reset", "--hard", "origin/main"]) + expect(mockGit.raw).toHaveBeenCalledWith(["clean", "-f", "-d"]) + expect(mockGit.clone).not.toHaveBeenCalled() + }) + + it("should handle clone failures", async () => { + const mockGit = { + ...mockSimpleGit(), + clone: jest.fn().mockRejectedValue(new Error("fatal: repository not found")), + pull: jest.fn(), + revparse: jest.fn(), + } as unknown as SimpleGit + mockSimpleGit.mockReturnValue(mockGit) + + await expect(gitFetcher.fetchRepository(testRepoUrl)).rejects.toThrow(/Failed to clone\/pull repository/) + + // Verify cleanup was called + expect(fs.rm).toHaveBeenCalledWith(testRepoDir, { recursive: true, force: true }) + }) + + it("should handle pull failures and re-clone", async () => { + // Mock repository exists + ;(fs.stat as jest.Mock).mockImplementation((path: string) => { + if (path.endsWith(".git")) return Promise.resolve(true) + if (path.endsWith("metadata.en.yml")) return Promise.resolve(true) + if (path.endsWith("README.md")) return Promise.resolve(true) + return Promise.reject(new Error("ENOENT")) + }) + + // Reset fs.rm mock to track calls + ;(fs.rm as jest.Mock).mockReset() + ;(fs.rm as jest.Mock).mockImplementation((path: string, options?: any) => { + if (path === testRepoDir && options?.recursive && options?.force) { + return Promise.resolve(undefined) + } + return Promise.reject(new Error("Invalid rm call")) + }) + + const mockGit = { + clone: jest.fn().mockResolvedValue(undefined), + pull: jest.fn().mockRejectedValue(new Error("not a git repository")), + revparse: jest.fn().mockResolvedValue("main"), + fetch: jest.fn().mockRejectedValue(new Error("not a git repository")), + clean: jest.fn(), + raw: jest.fn(), + } as unknown as SimpleGit + mockSimpleGit.mockReturnValue(mockGit) + + await gitFetcher.fetchRepository(testRepoUrl) + + // Verify directory was removed and repository was re-cloned + // First rm call is for cleanup before clone + expect(fs.rm).toHaveBeenCalledWith(testRepoDir, { recursive: true, force: true }) + // Second rm call is after pull failure + expect(fs.rm).toHaveBeenCalledWith(testRepoDir, { recursive: true, force: true }) + expect(mockGit.clone).toHaveBeenCalledWith(testRepoUrl, testRepoDir) + expect(mockGit.raw).toHaveBeenCalledWith(["clean", "-f", "-d"]) + expect(mockGit.raw).toHaveBeenCalledWith(["reset", "--hard", "HEAD"]) + }) + + it("should handle missing metadata.yml", async () => { + // Mock repository exists but missing metadata + ;(fs.stat as jest.Mock).mockImplementation((path: string) => { + if (path.endsWith("metadata.en.yml")) return Promise.reject(new Error("ENOENT")) + return Promise.resolve(true) + }) + + await expect(gitFetcher.fetchRepository(testRepoUrl)).rejects.toThrow( + 'Invalid repository structure: could not find "registry" metadata', + ) + }) + }) + + describe("Git Lock File Handling", () => { + it("should clean up index.lock file before operations", async () => { + // Mock repository exists + ;(fs.stat as jest.Mock).mockImplementation((path: string) => { + if (path.endsWith(".git")) return Promise.resolve(true) + if (path.endsWith("metadata.en.yml")) return Promise.resolve(true) + if (path.endsWith("README.md")) return Promise.resolve(true) + return Promise.reject(new Error("ENOENT")) + }) + + await gitFetcher.fetchRepository(testRepoUrl) + + // Verify lock file cleanup was attempted + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("index.lock")) + }) + + it("should handle missing lock file gracefully", async () => { + // Mock unlink to fail as if file doesn't exist + ;(fs.unlink as jest.Mock).mockRejectedValue(new Error("ENOENT")) + + await gitFetcher.fetchRepository(testRepoUrl) + + // Operation should succeed despite lock file not existing + const mockGit = mockSimpleGit() + expect(mockGit.clone).toHaveBeenCalled() + }) + + it("should clean up lock file when pull fails", async () => { + // Mock repository exists + ;(fs.stat as jest.Mock).mockImplementation((path: string) => { + if (path.endsWith(".git")) return Promise.resolve(true) + if (path.endsWith("metadata.en.yml")) return Promise.resolve(true) + if (path.endsWith("README.md")) return Promise.resolve(true) + return Promise.reject(new Error("ENOENT")) + }) + + const mockGit = { + clone: jest.fn().mockResolvedValue(undefined), + pull: jest.fn().mockRejectedValue(new Error("not a git repository")), + revparse: jest.fn().mockResolvedValue("main"), + fetch: jest.fn().mockRejectedValue(new Error("not a git repository")), + clean: jest.fn(), + raw: jest.fn(), + } as unknown as SimpleGit + mockSimpleGit.mockReturnValue(mockGit) + + await gitFetcher.fetchRepository(testRepoUrl) + + // Verify lock file cleanup was attempted after pull failure + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("index.lock")) + }) + }) + + describe("Repository Structure Validation", () => { + // Helper function to access private method + const validateRegistryStructure = async (repoDir: string) => { + return (gitFetcher as any).validateRegistryStructure(repoDir) + } + + describe("metadata.en.yml validation", () => { + it("should throw error when metadata.en.yml is missing", async () => { + // Mock fs.stat to simulate missing file + ;(fs.stat as jest.Mock).mockImplementation((path: string) => { + if (path.endsWith("metadata.en.yml")) return Promise.reject(new Error("File not found")) + return Promise.resolve({} as any) + }) + + // Call the method and expect it to throw + await expect(validateRegistryStructure("/mock/repo")).rejects.toThrow( + "Registry is missing metadata.en.yml file", + ) + }) + + it("should pass when metadata.en.yml exists", async () => { + // Mock fs.stat to simulate existing file + ;(fs.stat as jest.Mock).mockImplementation(() => { + return Promise.resolve({} as any) + }) + + // Call the method and expect it not to throw + await expect(validateRegistryStructure("/mock/repo")).resolves.not.toThrow() + }) + }) + }) + + describe("Git Command Handling with Special Paths", () => { + beforeEach(() => { + // Reset fs.stat mock to default behavior + ;(fs.stat as jest.Mock).mockImplementation((path: string) => { + if (path.endsWith(".git")) return Promise.reject(new Error("ENOENT")) + if (path.endsWith("metadata.en.yml")) return Promise.resolve(true) + if (path.endsWith("README.md")) return Promise.resolve(true) + return Promise.reject(new Error("ENOENT")) + }) + + // No need to reset fs.rm mock here as it's handled in beforeEach + }) + + it("should handle paths with spaces when cloning", async () => { + const url = "https://github.com/example/repo" + + // Create a new GitFetcher instance + const gitFetcher = new GitFetcher(mockContext) + + // Attempt to fetch repository + await gitFetcher.fetchRepository(url) + + // Verify that simpleGit's clone was called with the correct arguments + const mockGit = mockSimpleGit() + expect(mockGit.clone).toHaveBeenCalledWith(url, expect.stringContaining("marketplace-cache")) + }) + + it("should handle paths with special characters when cloning", async () => { + const url = "https://github.com/example/repo-name" + + // Create a new GitFetcher instance + const gitFetcher = new GitFetcher(mockContext) + + // Attempt to fetch repository + await gitFetcher.fetchRepository(url) + + // Verify that simpleGit's clone was called with the correct arguments + const mockGit = mockSimpleGit() + expect(mockGit.clone).toHaveBeenCalledWith(url, expect.stringContaining("marketplace-cache")) + }) + }) +}) diff --git a/src/services/marketplace/__tests__/GitUrlValidation.test.ts b/src/services/marketplace/__tests__/GitUrlValidation.test.ts new file mode 100644 index 0000000000..d86ce4ff14 --- /dev/null +++ b/src/services/marketplace/__tests__/GitUrlValidation.test.ts @@ -0,0 +1,8 @@ +import { isValidGitRepositoryUrl } from "../../../shared/MarketplaceValidation" + +describe("Git URL Validation", () => { + test("validates multi-segment domain SSH URL", () => { + const url = "git@git.lab.company.com:team-name/project-name.git" + expect(isValidGitRepositoryUrl(url)).toBe(true) + }) +}) diff --git a/src/services/marketplace/__tests__/MarketplaceManager.test.ts b/src/services/marketplace/__tests__/MarketplaceManager.test.ts new file mode 100644 index 0000000000..8ec7585552 --- /dev/null +++ b/src/services/marketplace/__tests__/MarketplaceManager.test.ts @@ -0,0 +1,764 @@ +import { MarketplaceManager } from "../MarketplaceManager" +import { MarketplaceItem, MarketplaceSource, MarketplaceRepository, MarketplaceItemType } from "../types" +import { MetadataScanner } from "../MetadataScanner" +import { GitFetcher } from "../GitFetcher" +import * as path from "path" +import * as vscode from "vscode" + +describe("MarketplaceManager", () => { + describe("filterItems", () => { + // Create a mock context with required properties + const mockContext = { + globalStorageUri: { + fsPath: path.resolve(__dirname, "../../../../mock/settings/path"), + }, + extensionPath: path.resolve(__dirname, "../../../../"), + subscriptions: [], + workspaceState: { + get: jest.fn(), + update: jest.fn(), + }, + globalState: { + get: jest.fn(), + update: jest.fn(), + }, + asAbsolutePath: jest.fn((p) => p), + storagePath: "", + logPath: "", + extensionUri: { fsPath: "" }, + environmentVariableCollection: {}, + extensionMode: 1, + storageUri: { fsPath: "" }, + } as unknown as vscode.ExtensionContext + + let manager: MarketplaceManager + + beforeEach(() => { + // Create a new manager instance with the mock context for each test + manager = new MarketplaceManager(mockContext) + }) + + it("should correctly filter items by search term", () => { + const items: MarketplaceItem[] = [ + { + id: "test-item-1", + name: "Test Item 1", + description: "First test item", + version: "zxc", + type: "mode", + url: "test1", + repoUrl: "test1", + }, + { + id: "another-item", + name: "Another Item", + description: "Second item", + version: "zxc", + type: "mode", + url: "test2", + repoUrl: "test2", + }, + ] + + const filtered = manager.filterItems(items, { search: "test" }) + expect(filtered).toHaveLength(1) + expect(filtered[0].name).toBe("Test Item 1") + expect(filtered[0].matchInfo?.matched).toBe(true) + }) + + it("should correctly filter items by type", () => { + const items: MarketplaceItem[] = [ + { + id: "mode-item", + name: "Mode Item", + description: "A mode", + version: "zxc", + type: "mode", + url: "test1", + repoUrl: "test1", + }, + { + id: "server-item", + name: "Server Item", + description: "A server", + version: "zxc", + type: "mcp", + url: "test2", + repoUrl: "test2", + }, + ] + + const filtered = manager.filterItems(items, { type: "mode" }) + expect(filtered).toHaveLength(1) + expect(filtered[0].name).toBe("Mode Item") + expect(filtered[0].matchInfo?.matchReason?.typeMatch).toBe(true) + }) + + it("should preserve original items when filtering", () => { + const items: MarketplaceItem[] = [ + { + id: "test-item-1", + name: "Test Item 1", + description: "First test item", + version: "zxc", + type: "mode", + url: "test1", + repoUrl: "test1", + }, + { + id: "another-item", + name: "Another Item", + description: "Second item", + version: "zxc", + type: "mode", + url: "test2", + repoUrl: "test2", + }, + ] + + const originalItemsJson = JSON.stringify(items) + manager.filterItems(items, { search: "test" }) + expect(JSON.stringify(items)).toBe(originalItemsJson) + }) + }) + + let manager: MarketplaceManager + + beforeEach(() => { + const context = { + globalStorageUri: { fsPath: path.resolve(__dirname, "../../../../mock/settings/path") }, + } as vscode.ExtensionContext + manager = new MarketplaceManager(context) + }) + + describe("Type Filter Behavior", () => { + let typeFilterTestItems: MarketplaceItem[] + + test("should include package with MCP server subcomponent when filtering by type 'mcp'", () => { + const items: MarketplaceItem[] = [ + { + id: "data-platform-package", + name: "Data Platform Package", + description: "A package containing MCP servers", + version: "zxc", + type: "package" as MarketplaceItemType, + url: "test/package", + repoUrl: "https://example.com", + items: [ + { + type: "mcp" as MarketplaceItemType, + path: "test/server", + metadata: { + name: "Data Validator", + description: "An MCP server", + version: "1.0.0", + type: "mcp" as MarketplaceItemType, + }, + }, + ], + }, + { + id: "standalone-server", + name: "Standalone Server", + description: "A standalone MCP server", + version: "zxc", + type: "mcp" as MarketplaceItemType, + url: "test/server", + repoUrl: "https://example.com", + }, + ] + + const filtered = manager.filterItems(items, { type: "mcp" }) + expect(filtered.length).toBe(2) + expect(filtered.map((item) => item.name)).toContain("Data Platform Package") + expect(filtered.map((item) => item.name)).toContain("Standalone Server") + + // Verify package is included because of its MCP server subcomponent + const pkg = filtered.find((item) => item.name === "Data Platform Package") + expect(pkg?.matchInfo?.matched).toBe(true) + expect(pkg?.matchInfo?.matchReason?.hasMatchingSubcomponents).toBe(true) + expect(pkg?.items?.[0].matchInfo?.matched).toBe(true) + expect(pkg?.items?.[0].matchInfo?.matchReason?.typeMatch).toBe(true) + }) + + test("should include package when filtering by subcomponent type", () => { + const items: MarketplaceItem[] = [ + { + id: "data-platform-package", + name: "Data Platform Package", + description: "A package containing MCP servers", + version: "zxc", + type: "package" as MarketplaceItemType, + url: "test/package", + repoUrl: "https://example.com", + items: [ + { + type: "mcp" as MarketplaceItemType, + path: "test/server", + metadata: { + name: "Data Validator", + description: "An MCP server", + version: "1.0.0", + type: "mcp" as MarketplaceItemType, + }, + }, + ], + }, + ] + + const filtered = manager.filterItems(items, { type: "mcp" }) + expect(filtered.length).toBe(1) + expect(filtered[0].name).toBe("Data Platform Package") + expect(filtered[0].matchInfo?.matched).toBe(true) + expect(filtered[0].items?.[0].matchInfo?.matched).toBe(true) + expect(filtered[0].items?.[0].matchInfo?.matchReason?.typeMatch).toBe(true) + }) + + beforeEach(() => { + // Create test items + typeFilterTestItems = [ + { + id: "test-package", + name: "Test Package", + description: "A test package", + version: "zxc", + type: "package", + url: "test/package", + repoUrl: "https://example.com", + items: [ + { + type: "mode", + path: "test/mode", + metadata: { + name: "Test Mode", + description: "A test mode", + version: "1.0.0", + type: "mode", + }, + }, + { + type: "mcp", + path: "test/server", + metadata: { + name: "Test Server", + description: "A test server", + version: "1.0.0", + type: "mcp", + }, + }, + ], + }, + { + id: "test-mode", + name: "Test Mode", + description: "A standalone test mode", + version: "zxc", + type: "mode", + url: "test/standalone-mode", + repoUrl: "https://example.com", + }, + ] + }) + + // Concurrency Control tests moved to their own describe block + + test("should include package when filtering by its own type", () => { + // Filter by package type + const filtered = manager.filterItems(typeFilterTestItems, { type: "package" }) + + // Should include the package + expect(filtered.length).toBe(1) + expect(filtered[0].name).toBe("Test Package") + expect(filtered[0].matchInfo?.matched).toBe(true) + expect(filtered[0].matchInfo?.matchReason?.typeMatch).toBe(true) + }) + + // Note: The test "should include package when filtering by subcomponent type" is already covered by + // the test "should work with type filter and localization together" in the filterItems with subcomponents section + + test("should not include package when filtering by type with no matching subcomponents", () => { + // Create a package with no matching subcomponents + const noMatchPackage: MarketplaceItem = { + id: "no-match-package", + name: "No Match Package", + description: "A package with no matching subcomponents", + version: "zxc", + type: "package", + url: "test/no-match", + repoUrl: "https://example.com", + items: [ + { + type: "prompt", + path: "test/prompt", + metadata: { + name: "Test Prompt", + description: "A test prompt", + version: "1.0.0", + type: "prompt", + }, + }, + ], + } + + // Filter by mode type + const filtered = manager.filterItems([noMatchPackage], { type: "mode" }) + + // Should not include the package + expect(filtered.length).toBe(0) + }) + + test("should handle package with no subcomponents", () => { + // Create a package with no subcomponents + const noSubcomponentsPackage: MarketplaceItem = { + id: "no-subcomponents-package", + name: "No Subcomponents Package", + description: "A package with no subcomponents", + version: "zxc", + type: "package", + url: "test/no-subcomponents", + repoUrl: "https://example.com", + } + + // Filter by mode type + const filtered = manager.filterItems([noSubcomponentsPackage], { type: "mode" }) + + // Should not include the package + expect(filtered.length).toBe(0) + }) + + describe("Consistency with Search Term Behavior", () => { + let consistencyTestItems: MarketplaceItem[] + + beforeEach(() => { + // Create test items + consistencyTestItems = [ + { + id: "test-package", + name: "Test Package", + description: "A test package", + version: "zxc", + type: "package", + url: "test/package", + repoUrl: "https://example.com", + items: [ + { + type: "mode", + path: "test/mode", + metadata: { + name: "Test Mode", + description: "A test mode", + version: "1.0.0", + type: "mode", + }, + }, + ], + }, + ] + }) + + test("should behave consistently with search term for packages", () => { + // Filter by type + const typeFiltered = manager.filterItems(consistencyTestItems, { type: "package" }) + + // Filter by search term that matches the package + const searchFiltered = manager.filterItems(consistencyTestItems, { search: "test package" }) + + // Both should include the package + expect(typeFiltered.length).toBe(1) + expect(searchFiltered.length).toBe(1) + + // Both should mark the package as matched + expect(typeFiltered[0].matchInfo?.matched).toBe(true) + expect(searchFiltered[0].matchInfo?.matched).toBe(true) + }) + + test("should behave consistently with search term for subcomponents", () => { + // Filter by type that matches a subcomponent + const typeFiltered = manager.filterItems(consistencyTestItems, { type: "mode" }) + + // Filter by search term that matches a subcomponent + const searchFiltered = manager.filterItems(consistencyTestItems, { search: "test mode" }) + + // Both should include the package + expect(typeFiltered.length).toBe(1) + expect(searchFiltered.length).toBe(1) + + // Both should mark the package as matched + expect(typeFiltered[0].matchInfo?.matched).toBe(true) + expect(searchFiltered[0].matchInfo?.matched).toBe(true) + + // Both should mark the subcomponent as matched + expect(typeFiltered[0].items?.[0].matchInfo?.matched).toBe(true) + expect(searchFiltered[0].items?.[0].matchInfo?.matched).toBe(true) + }) + }) + }) + + describe("sortItems with subcomponents", () => { + const testItems: MarketplaceItem[] = [ + { + id: "b-package", + name: "B Package", + description: "Package B", + type: "package", + version: "1.0.0", + url: "/test/b", + repoUrl: "https://example.com", + items: [ + { + type: "mode", + path: "modes/y", + metadata: { + name: "Y Mode", + description: "Mode Y", + type: "mode", + version: "1.0.0", + }, + lastUpdated: "2025-04-13T09:00:00-07:00", + }, + { + type: "mode", + path: "modes/x", + metadata: { + name: "X Mode", + description: "Mode X", + type: "mode", + version: "1.0.0", + }, + lastUpdated: "2025-04-13T09:00:00-07:00", + }, + ], + }, + { + id: "a-package", + name: "A Package", + description: "Package A", + type: "package", + version: "1.0.0", + url: "/test/a", + repoUrl: "https://example.com", + items: [ + { + type: "mode", + path: "modes/z", + metadata: { + name: "Z Mode", + description: "Mode Z", + type: "mode", + version: "1.0.0", + }, + lastUpdated: "2025-04-13T08:00:00-07:00", + }, + ], + }, + ] + + it("should sort parent items while preserving subcomponents", () => { + const sorted = manager.sortItems(testItems, "name", "asc") + expect(sorted[0].name).toBe("A Package") + expect(sorted[1].name).toBe("B Package") + expect(sorted[0].items![0].metadata!.name).toBe("Z Mode") + expect(sorted[1].items![0].metadata!.name).toBe("Y Mode") + }) + + it("should sort subcomponents within parents", () => { + const sorted = manager.sortItems(testItems, "name", "asc", true) + expect(sorted[1].items![0].metadata!.name).toBe("X Mode") + expect(sorted[1].items![1].metadata!.name).toBe("Y Mode") + }) + + it("should preserve subcomponent order when sortSubcomponents is false", () => { + const sorted = manager.sortItems(testItems, "name", "asc", false) + expect(sorted[1].items![0].metadata!.name).toBe("Y Mode") + expect(sorted[1].items![1].metadata!.name).toBe("X Mode") + }) + + it("should handle empty subcomponents when sorting", () => { + const itemsWithEmpty = [ + ...testItems, + { + id: "c-package", + name: "C Package", + description: "Package C", + type: "package" as const, + version: "1.0.0", + url: "/test/c", + repoUrl: "https://example.com", + items: [], + } as MarketplaceItem, + ] + const sorted = manager.sortItems(itemsWithEmpty, "name", "asc") + expect(sorted[2].name).toBe("C Package") + expect(sorted[2].items).toHaveLength(0) + }) + }) + + describe("filterItems with real data", () => { + it("should return all subcomponents with match info", () => { + const testItems: MarketplaceItem[] = [ + { + id: "data-platform-package", + name: "Data Platform Package", + description: "A test platform", + type: "package", + version: "1.0.0", + url: "/test/data-platform", + repoUrl: "https://example.com", + items: [ + { + type: "mcp", + path: "mcps/data-validator", + metadata: { + name: "Data Validator", + description: "An MCP server for validating data quality", + type: "mcp", + version: "1.0.0", + }, + lastUpdated: "2025-04-13T10:00:00-07:00", + }, + { + type: "mode", + path: "modes/task-runner", + metadata: { + name: "Task Runner", + description: "A mode for running tasks", + type: "mode", + version: "1.0.0", + }, + lastUpdated: "2025-04-13T10:00:00-07:00", + }, + ], + }, + ] + + // Search for "data validator" + const filtered = manager.filterItems(testItems, { search: "data validator" }) + + // Verify package is returned + expect(filtered.length).toBe(1) + const pkg = filtered[0] + + // Verify all subcomponents are returned + expect(pkg.items?.length).toBe(2) + + // Verify matching subcomponent has correct matchInfo + const validator = pkg.items?.find((item) => item.metadata?.name === "Data Validator") + expect(validator?.matchInfo).toEqual({ + matched: true, + matchReason: { + nameMatch: true, + descriptionMatch: false, + }, + }) + + // Verify non-matching subcomponent has correct matchInfo + const runner = pkg.items?.find((item) => item.metadata?.name === "Task Runner") + expect(runner?.matchInfo).toEqual({ + matched: false, + }) + + // Verify package has matchInfo indicating it contains matches + expect(pkg.matchInfo).toEqual({ + matched: true, + matchReason: { + nameMatch: false, + descriptionMatch: false, + hasMatchingSubcomponents: true, + }, + }) + }) + }) +}) + +describe("Source Attribution", () => { + let manager: MarketplaceManager + + beforeEach(() => { + const mockContext = { + globalStorageUri: { fsPath: "/test/path" }, + } as vscode.ExtensionContext + manager = new MarketplaceManager(mockContext) + }) + + it("should maintain source attribution for items", async () => { + const sources: MarketplaceSource[] = [ + { url: "https://github.com/test/repo1", name: "Source 1", enabled: true }, + { url: "https://github.com/test/repo2", name: "Source 2", enabled: true }, + ] + + // Mock getRepositoryData to return different items for each source + jest.spyOn(manager as any, "getRepositoryData") + .mockImplementationOnce(() => + Promise.resolve({ + metadata: { name: "test", description: "test", version: "1.0.0" }, + items: [ + { + id: "item-1", + name: "Item 1", + type: "mode", + description: "Test item", + url: "test1", + repoUrl: "https://github.com/test/repo1", + }, + ], + url: sources[0].url, + }), + ) + .mockImplementationOnce(() => + Promise.resolve({ + metadata: { name: "test", description: "test", version: "1.0.0" }, + items: [], + url: sources[1].url, + }), + ) + + const result = await manager.getMarketplaceItems(sources) + + // Verify items maintain their source attribution + expect(result.items).toHaveLength(1) + expect(result.items[0].sourceName).toBe("Source 1") + expect(result.items[0].sourceUrl).toBe("https://github.com/test/repo1") + }) +}) + +describe("Concurrency Control", () => { + let manager: MarketplaceManager + + beforeEach(() => { + const mockContext = { + globalStorageUri: { fsPath: "/test/path" }, + } as vscode.ExtensionContext + manager = new MarketplaceManager(mockContext) + }) + + it("should not allow concurrent operations on the same source", async () => { + const source: MarketplaceSource = { + url: "https://github.com/test/repo", + enabled: true, + } + + // Mock getRepositoryData to return a resolved promise immediately + const getRepoSpy = jest.spyOn(manager as any, "getRepositoryData").mockImplementation(() => + Promise.resolve({ + metadata: { name: "test", description: "test", version: "1.0.0" }, + items: [], + url: source.url, + } as MarketplaceRepository), + ) + + // Start two concurrent operations + const operation1 = manager.getMarketplaceItems([source]) + const operation2 = manager.getMarketplaceItems([source]) + + // Wait for both to complete + // const [result1, result2] = + await Promise.all([operation1, operation2]) + + // Verify getRepositoryData was only called once + expect(getRepoSpy).toHaveBeenCalledTimes(1) + + // Clean up + getRepoSpy.mockRestore() + }) + + it("should not allow metadata scanning during git operations", async () => { + try { + const source1: MarketplaceSource = { + url: "https://github.com/test/repo1", + enabled: true, + } + const source2: MarketplaceSource = { + url: "https://github.com/test/repo2", + enabled: true, + } + + let isGitOperationActive = false + let metadataScanDuringGit = false + + // Mock git operation to resolve immediately + // const fetchRepoSpy = + jest.spyOn(GitFetcher.prototype, "fetchRepository").mockImplementation(async () => { + isGitOperationActive = true + isGitOperationActive = false + return { + metadata: { name: "test", description: "test", version: "1.0.0" }, + items: [], + url: source1.url, + } + }) + + // Mock metadata scanner to check if git operation is active + // const scanDirSpy = + jest.spyOn(MetadataScanner.prototype, "scanDirectory").mockImplementation(async () => { + if (isGitOperationActive) { + metadataScanDuringGit = true + } + return [] + }) + + // Process both sources + await manager.getMarketplaceItems([source1, source2]) + + // Verify metadata scanning didn't occur during git operations + expect(metadataScanDuringGit).toBe(false) + } finally { + jest.clearAllTimers() + } + }) + + it("should queue metadata scans and process them sequentially", async () => { + const sources: MarketplaceSource[] = [ + { url: "https://github.com/test/repo1", enabled: true }, + { url: "https://github.com/test/repo2", enabled: true }, + { url: "https://github.com/test/repo3", enabled: true }, + ] + + let activeScans = 0 + let maxConcurrentScans = 0 + + // Create a mock MetadataScanner that resolves immediately + const mockScanner = new MetadataScanner() + const scanDirectorySpy = jest.spyOn(mockScanner, "scanDirectory").mockImplementation(async () => { + activeScans++ + maxConcurrentScans = Math.max(maxConcurrentScans, activeScans) + activeScans-- + return Promise.resolve([]) + }) + + // Create a mock GitFetcher that uses our mock scanner + const mockGitFetcher = new GitFetcher({ + globalStorageUri: { fsPath: "/test/path" }, + } as vscode.ExtensionContext) + + // Replace GitFetcher's metadataScanner with our mock + ;(mockGitFetcher as any).metadataScanner = mockScanner + + // Mock GitFetcher's fetchRepository to trigger metadata scanning + const fetchRepoSpy = jest + .spyOn(mockGitFetcher, "fetchRepository") + .mockImplementation(async (repoUrl: string) => { + // Call scanDirectory through our mock scanner + await mockScanner.scanDirectory("/test/path", repoUrl) + + return Promise.resolve({ + metadata: { name: "test", description: "test", version: "1.0.0" }, + items: [], + url: repoUrl, + }) + }) + + // Replace the GitFetcher instance in the manager + ;(manager as any).gitFetcher = mockGitFetcher + + // Process all sources + await manager.getMarketplaceItems(sources) + + // Verify scans were called and only one was active at a time + expect(scanDirectorySpy).toHaveBeenCalledTimes(sources.length) + expect(maxConcurrentScans).toBe(1) + + // Clean up + scanDirectorySpy.mockRestore() + fetchRepoSpy.mockRestore() + }) +}) diff --git a/src/services/marketplace/__tests__/MarketplaceSourceValidation.test.ts b/src/services/marketplace/__tests__/MarketplaceSourceValidation.test.ts new file mode 100644 index 0000000000..6edd6e85f9 --- /dev/null +++ b/src/services/marketplace/__tests__/MarketplaceSourceValidation.test.ts @@ -0,0 +1,237 @@ +import { + isValidGitRepositoryUrl, + validateSourceUrl, + validateSourceName, + validateSourceDuplicates, + validateSource, + validateSources, +} from "../../../shared/MarketplaceValidation" +import { MarketplaceSource } from "../types" + +describe("MarketplaceSourceValidation", () => { + describe("isValidGitRepositoryUrl", () => { + const validUrls = [ + "https://github.com/username/repo", + "https://gitlab.com/username/repo", + "https://bitbucket.org/username/repo", + + // Custom/self-hosted domains + "https://git.company.com/username/repo", + "https://git.internal.dev/username/repo.git", + "git@git.company.com:username/repo.git", + "git://git.internal.dev/username/repo.git", + + // Subdomains and longer TLDs + "https://git.dev.company.co.uk/username/repo", + "git@git.dev.internal.company.com:username/repo.git", + ] + + const invalidUrls = [ + "", + " ", + "not-a-url", + "https://example.com", // Missing username/repo parts + "git@example.com", // Missing repo part + "https://git.company.com/repo", // Missing username part + "git://example.com/repo", // Missing username part + "https://git.company.com/", // Missing both username and repo + ] + + test.each(validUrls)("should accept valid URL: %s", (url) => { + expect(isValidGitRepositoryUrl(url)).toBe(true) + }) + + test.each(invalidUrls)("should reject invalid URL: %s", (url) => { + expect(isValidGitRepositoryUrl(url)).toBe(false) + }) + }) + + describe("validateSourceUrl", () => { + test("should accept valid URLs", () => { + const errors = validateSourceUrl("https://github.com/username/repo") + expect(errors).toHaveLength(0) + }) + + test("should reject empty URL", () => { + const errors = validateSourceUrl("") + expect(errors).toHaveLength(1) + expect(errors[0]).toEqual({ + field: "url", + message: "URL cannot be empty", + }) + }) + + test("should reject invalid URL format", () => { + const errors = validateSourceUrl("not-a-url") + expect(errors).toHaveLength(1) + expect(errors[0]).toEqual({ + field: "url", + message: "URL must be a valid Git repository URL (e.g., https://git.example.com/username/repo)", + }) + }) + + test("should reject URLs with non-visible characters", () => { + const errors = validateSourceUrl("https://github.com/username/repo\t") + expect(errors).toHaveLength(1) + expect(errors[0]).toEqual({ + field: "url", + message: "URL contains non-visible characters other than spaces", + }) + }) + + test("should reject non-Git repository URLs", () => { + const errors = validateSourceUrl("https://example.com/path") + expect(errors).toHaveLength(1) + expect(errors[0]).toEqual({ + field: "url", + message: "URL must be a valid Git repository URL (e.g., https://git.example.com/username/repo)", + }) + }) + }) + + describe("validateSourceName", () => { + test("should accept valid names", () => { + const errors = validateSourceName("Valid Name") + expect(errors).toHaveLength(0) + }) + + test("should accept undefined name", () => { + const errors = validateSourceName(undefined) + expect(errors).toHaveLength(0) + }) + + test("should reject names longer than 20 characters", () => { + const errors = validateSourceName("This name is way too long to be valid") + expect(errors).toHaveLength(1) + expect(errors[0]).toEqual({ + field: "name", + message: "Name must be 20 characters or less", + }) + }) + + test("should reject names with non-visible characters", () => { + const errors = validateSourceName("Invalid\tName") + expect(errors).toHaveLength(1) + expect(errors[0]).toEqual({ + field: "name", + message: "Name contains non-visible characters other than spaces", + }) + }) + }) + + describe("validateSourceDuplicates", () => { + const existingSources: MarketplaceSource[] = [ + { url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true }, + { url: "https://git.company.com/user2/repo2", name: "Source 2", enabled: true }, + ] + + test("should accept unique sources", () => { + const newSource: MarketplaceSource = { + url: "https://git.company.com/user3/repo3", + name: "Source 3", + enabled: true, + } + const errors = validateSourceDuplicates(existingSources, newSource) + expect(errors).toHaveLength(0) + }) + + test("should reject duplicate URLs (case insensitive)", () => { + const newSource: MarketplaceSource = { + url: "HTTPS://GIT.COMPANY.COM/USER1/REPO1", + name: "Different Name", + enabled: true, + } + const errors = validateSourceDuplicates(existingSources, newSource) + expect(errors).toHaveLength(1) + expect(errors[0].field).toBe("url") + expect(errors[0].message).toContain("duplicate") + }) + + test("should reject duplicate names (case insensitive)", () => { + const newSource: MarketplaceSource = { + url: "https://git.company.com/user3/repo3", + name: "SOURCE 1", + enabled: true, + } + const errors = validateSourceDuplicates(existingSources, newSource) + expect(errors).toHaveLength(1) + expect(errors[0].field).toBe("name") + expect(errors[0].message).toContain("duplicate") + }) + + test("should detect duplicates within source list", () => { + const sourcesWithDuplicates: MarketplaceSource[] = [ + { url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true }, + { url: "https://git.company.com/user1/repo1", name: "Source 2", enabled: true }, // Duplicate URL + { url: "https://git.company.com/user3/repo3", name: "Source 1", enabled: true }, // Duplicate name + ] + const errors = validateSourceDuplicates(sourcesWithDuplicates) + expect(errors).toHaveLength(4) // Two URL duplicates (bidirectional) and two name duplicates (bidirectional) + + // Check for URL duplicates + const urlErrors = errors.filter((e) => e.field === "url") + expect(urlErrors).toHaveLength(2) + expect(urlErrors[0].message).toContain("Source #1 has a duplicate URL with Source #2") + expect(urlErrors[1].message).toContain("Source #2 has a duplicate URL with Source #1") + + // Check for name duplicates + const nameErrors = errors.filter((e) => e.field === "name") + expect(nameErrors).toHaveLength(2) + expect(nameErrors[0].message).toContain("Source #1 has a duplicate name with Source #3") + expect(nameErrors[1].message).toContain("Source #3 has a duplicate name with Source #1") + }) + }) + + describe("validateSource", () => { + const existingSources: MarketplaceSource[] = [ + { url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true }, + ] + + test("should accept valid source", () => { + const source: MarketplaceSource = { + url: "https://git.company.com/user2/repo2", + name: "Source 2", + enabled: true, + } + const errors = validateSource(source, existingSources) + expect(errors).toHaveLength(0) + }) + + test("should accumulate multiple validation errors", () => { + const source: MarketplaceSource = { + url: "https://git.company.com/user1/repo1", // Duplicate URL + name: "This name is way too long to be valid\t", // Too long and has tab + enabled: true, + } + const errors = validateSource(source, existingSources) + expect(errors.length).toBeGreaterThan(1) + }) + }) + + describe("validateSources", () => { + test("should accept valid source list", () => { + const sources: MarketplaceSource[] = [ + { url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true }, + { url: "https://git.company.com/user2/repo2", name: "Source 2", enabled: true }, + ] + const errors = validateSources(sources) + expect(errors).toHaveLength(0) + }) + + test("should detect multiple issues across sources", () => { + const sources: MarketplaceSource[] = [ + { url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true }, + { url: "https://git.company.com/user1/repo1", name: "Source 1", enabled: true }, // Duplicate URL and name + { url: "invalid-url", name: "This name is way too long\t", enabled: true }, // Invalid URL and name + ] + const errors = validateSources(sources) + expect(errors.length).toBeGreaterThan(2) + }) + + test("should include source index in error messages", () => { + const sources: MarketplaceSource[] = [{ url: "invalid-url", name: "Source 1", enabled: true }] + const errors = validateSources(sources) + expect(errors[0].message).toContain("Source #1") + }) + }) +}) diff --git a/src/services/marketplace/__tests__/MetadataScanner.external.test.ts b/src/services/marketplace/__tests__/MetadataScanner.external.test.ts new file mode 100644 index 0000000000..a06acac1bb --- /dev/null +++ b/src/services/marketplace/__tests__/MetadataScanner.external.test.ts @@ -0,0 +1,41 @@ +import * as path from "path" +import { GitFetcher } from "../GitFetcher" +import * as vscode from "vscode" + +describe("MetadataScanner External References", () => { + // TODO: remove this note + // This test is expected to fail until we update the registry with the new wordings (`mcp server` => `mcp`) + it.skip("should find all subcomponents in Project Manager package including external references", async () => { + // Create a GitFetcher instance using the project's mock settings directory + const mockContext = { + globalStorageUri: { fsPath: path.resolve(__dirname, "../../../../mock/settings") }, + } as vscode.ExtensionContext + const gitFetcher = new GitFetcher(mockContext) + + // Fetch the marketplace repository + const repoUrl = "https://github.com/RooVetGit/Roo-Code-Marketplace" + const repo = await gitFetcher.fetchRepository(repoUrl) + + // Find the Project Manager package + const projectManager = repo.items.find((item) => item.name === "Project Manager Package") + expect(projectManager).toBeDefined() + expect(projectManager?.type).toBe("package") + + // Verify it has exactly 2 subcomponents + expect(projectManager?.items).toBeDefined() + expect(projectManager?.items?.length).toBe(2) + + // Verify one is a mode and one is an MCP server + const hasMode = projectManager?.items?.some((item) => item.type === "mode") + const hasMcpServer = projectManager?.items?.some((item) => item.type === "mcp") + expect(hasMode).toBe(true) + expect(hasMcpServer).toBe(true) + + // Verify the MCP server is the Smartsheet component + const smartsheet = projectManager?.items?.find( + (item) => item.metadata?.name === "Smartsheet MCP - Project Management", + ) + expect(smartsheet).toBeDefined() + expect(smartsheet?.type).toBe("mcp") + }) +}) diff --git a/src/services/marketplace/__tests__/MetadataScanner.test.ts b/src/services/marketplace/__tests__/MetadataScanner.test.ts new file mode 100644 index 0000000000..85f4bece93 --- /dev/null +++ b/src/services/marketplace/__tests__/MetadataScanner.test.ts @@ -0,0 +1,157 @@ +jest.mock("fs/promises", () => { + const mockStat = jest.fn() + const mockReaddir = jest.fn() + const mockReadFile = jest.fn() + return { + stat: mockStat, + readdir: mockReaddir, + readFile: mockReadFile, + } +}) + +import * as path from "path" +import { jest } from "@jest/globals" +import { Dirent, Stats } from "fs" +import { MetadataScanner } from "../MetadataScanner" +import { SimpleGit } from "simple-git" +import * as fs from "fs/promises" + +// Helper function to normalize paths for test assertions +const normalizePath = (p: string) => p.replace(/\\/g, "/") + +// Create mock git functions with proper types +const mockGitRaw = jest.fn<() => Promise>() +const mockGitRevparse = jest.fn<() => Promise>() + +describe("MetadataScanner", () => { + let metadataScanner: MetadataScanner + const mockBasePath = "/test/repo" + const mockRepoUrl = "https://example.com/repo" + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks() + + // Create mock git instance with default date + const mockGit = { + raw: mockGitRaw.mockResolvedValue("2025-04-13T09:00:00-07:00"), + revparse: mockGitRevparse.mockResolvedValue("main"), + } as unknown as SimpleGit + + // Initialize MetadataScanner with mock git + metadataScanner = new MetadataScanner(mockGit) + }) + + describe("Basic Metadata Scanning", () => { + it("should discover components with English metadata", async () => { + // Setup mock implementations + const mockStats = { + isDirectory: () => true, + isFile: () => true, + mtime: new Date(), + } as Stats + + // Mock fs.promises methods using type assertions + const mockedFs = jest.mocked(fs) + mockedFs.stat.mockResolvedValue(mockStats) + + // Define specific Dirent objects + const componentDirDirent: Dirent = { + name: "component1", + isDirectory: () => true, + isFile: () => false, + } as Dirent + const metadataFileDirent: Dirent = { + name: "metadata.en.yml", + isDirectory: () => false, + isFile: () => true, + } as Dirent + + // Refined mock implementation for fs.readdir + ;(mockedFs.readdir as any).mockImplementation(async (p: string, options?: any) => { + const normalizedP = normalizePath(p) + const normalizedBasePath = normalizePath(mockBasePath) + const normalizedComponentPath = normalizePath(path.join(mockBasePath, "component1")) + + if (normalizedP === normalizedBasePath) { + // For the base path, return only the component directory + const baseDirents = [componentDirDirent] + return options?.withFileTypes ? baseDirents : baseDirents.map((d) => d.name) + } else if (normalizedP === normalizedComponentPath) { + // For the component1 directory, return only the metadata file + const componentDirents = [metadataFileDirent] + return options?.withFileTypes ? componentDirents : componentDirents.map((d) => d.name) + } else { + // For any other path (deeper recursion), return empty + return options?.withFileTypes ? [] : [] + } + }) + + mockedFs.readFile.mockResolvedValue( + Buffer.from(` +name: Test Component +description: A test component +type: mcp +version: 1.0.0 +sourceUrl: https://example.com/component1 +`), + ) + + const items = await metadataScanner.scanDirectory(mockBasePath, mockRepoUrl) + + expect(items).toHaveLength(1) + expect(items[0].name).toBe("Test Component") + expect(items[0].type).toBe("mcp") + expect(items[0].url).toBe("https://example.com/repo/tree/main/component1") + expect(items[0].path).toBe("component1") + expect(items[0].sourceUrl).toBe("https://example.com/component1") + }) + it("should handle missing sourceUrl in metadata", async () => { + const mockDirents = [ + { + name: "component2", + isDirectory: () => true, + isFile: () => false, + }, + { + name: "metadata.en.yml", + isDirectory: () => false, + isFile: () => true, + }, + ] as Dirent[] + + const mockEmptyDirents = [] as Dirent[] + const mockStats = { + isDirectory: () => true, + isFile: () => true, + mtime: new Date(), + } as Stats + + const mockedFs = jest.mocked(fs) + mockedFs.stat.mockResolvedValue(mockStats) + ;(mockedFs.readdir as any).mockImplementation(async (path: any, options?: any) => { + if (path.toString().includes("/component2/")) { + return options?.withFileTypes ? mockEmptyDirents : [] + } + return options?.withFileTypes ? mockDirents : mockDirents.map((d) => d.name) + }) + mockedFs.readFile.mockResolvedValue( + Buffer.from(` +name: Test Component 2 +description: A test component without sourceUrl +type: mcp +version: 1.0.0 +`), + ) + + const items = await metadataScanner.scanDirectory(mockBasePath, mockRepoUrl) + + expect(items).toHaveLength(1) + expect(items[0].name).toBe("Test Component 2") + expect(items[0].type).toBe("mcp") + expect(items[0].url).toBe("https://example.com/repo/tree/main/component2") + expect(items[0].path).toBe("component2") + expect(items[0].sourceUrl).toBeUndefined() + }) + }) +}) diff --git a/src/services/marketplace/__tests__/schemas.test.ts b/src/services/marketplace/__tests__/schemas.test.ts new file mode 100644 index 0000000000..4a0f2b39a6 --- /dev/null +++ b/src/services/marketplace/__tests__/schemas.test.ts @@ -0,0 +1,133 @@ +import { + validateMetadata, + validateAnyMetadata, + repositoryMetadataSchema, + componentMetadataSchema, + packageMetadataSchema, +} from "../schemas" + +describe("Schema Validation", () => { + describe("validateMetadata", () => { + it("should validate repository metadata", () => { + const data = { + name: "Test Repository", + description: "A test repository", + version: "1.0.0", + tags: ["test"], + } + + expect(() => validateMetadata(data, repositoryMetadataSchema)).not.toThrow() + }) + + it("should validate component metadata", () => { + const data = { + name: "Test Component", + description: "A test component", + version: "1.0.0", + type: "mcp", + tags: ["test"], + } + + expect(() => validateMetadata(data, componentMetadataSchema)).not.toThrow() + }) + + it("should validate package metadata", () => { + const data = { + name: "Test Package", + description: "A test package", + version: "1.0.0", + type: "package", + items: [{ type: "mcp", path: "../external/server" }], + } + + expect(() => validateMetadata(data, packageMetadataSchema)).not.toThrow() + }) + + it("should throw error for missing required fields", () => { + const data = { + description: "Missing name", + version: "1.0.0", + } + + expect(() => validateMetadata(data, repositoryMetadataSchema)).toThrow("name: Name is required") + }) + + it("should throw error for invalid version format", () => { + const data = { + name: "Test", + description: "Test", + version: "invalid", + } + + expect(() => validateMetadata(data, repositoryMetadataSchema)).toThrow( + "version: Version must be in semver format", + ) + }) + }) + + describe("validateAnyMetadata", () => { + it("should auto-detect and validate repository metadata", () => { + const data = { + name: "Test Repository", + description: "A test repository", + version: "1.0.0", + } + + expect(() => validateAnyMetadata(data)).not.toThrow() + }) + + it("should auto-detect and validate component metadata", () => { + const data = { + name: "Test Component", + description: "A test component", + version: "1.0.0", + type: "mcp", + } + + expect(() => validateAnyMetadata(data)).not.toThrow() + }) + + it("should auto-detect and validate package metadata", () => { + const data = { + name: "Test Package", + description: "A test package", + version: "1.0.0", + type: "package", + items: [{ type: "mcp", path: "../external/server" }], + } + + expect(() => validateAnyMetadata(data)).not.toThrow() + }) + + it("should throw error for unknown component type", () => { + const data = { + name: "Test", + description: "Test", + version: "1.0.0", + type: "unknown", + } + + expect(() => validateAnyMetadata(data)).toThrow("Unknown component type: unknown") + }) + + it("should throw error for invalid external item reference", () => { + const data = { + name: "Test Package", + description: "Test package", + version: "1.0.0", + type: "package", + items: [{ type: "unknown", path: "../external/server" }], + } + + expect(() => validateAnyMetadata(data)).toThrow('type: Invalid value "unknown"') + }) + + it("should throw error for non-object input", () => { + expect(() => validateAnyMetadata("not an object")).toThrow("Invalid metadata: must be an object") + }) + + it("should throw error for null input", () => { + expect(() => validateAnyMetadata(null)).toThrow("Invalid metadata: must be an object") + }) + }) +}) diff --git a/src/services/marketplace/constants.ts b/src/services/marketplace/constants.ts new file mode 100644 index 0000000000..bf71234bcf --- /dev/null +++ b/src/services/marketplace/constants.ts @@ -0,0 +1,22 @@ +/** + * Constants for the marketplace + */ + +/** + * Default marketplace repository URL + */ +export const DEFAULT_PACKAGE_MANAGER_REPO_URL = "https://github.com/RooVetGit/Roo-Code-Marketplace" + +/** + * Default marketplace repository name + */ +export const DEFAULT_PACKAGE_MANAGER_REPO_NAME = "Roo Code" + +/** + * Default marketplace source + */ +export const DEFAULT_MARKETPLACE_SOURCE = { + url: DEFAULT_PACKAGE_MANAGER_REPO_URL, + name: DEFAULT_PACKAGE_MANAGER_REPO_NAME, + enabled: true, +} diff --git a/src/services/marketplace/index.ts b/src/services/marketplace/index.ts new file mode 100644 index 0000000000..3e49d781e6 --- /dev/null +++ b/src/services/marketplace/index.ts @@ -0,0 +1,4 @@ +export * from "./GitFetcher" +export * from "./MarketplaceManager" +export * from "./types" +export * from "../../shared/MarketplaceValidation" diff --git a/src/services/marketplace/schemas.ts b/src/services/marketplace/schemas.ts new file mode 100644 index 0000000000..5793bccd2d --- /dev/null +++ b/src/services/marketplace/schemas.ts @@ -0,0 +1,178 @@ +import { z } from "zod" + +/** + * Base metadata schema with common fields + */ +export const baseMetadataSchema = z.object({ + id: z.string().optional(), + name: z.string().min(1, "Name is required"), + description: z.string(), + version: z.string(), + binaryUrl: z.string().url("Binary URL must be a valid URL").optional(), + binaryHash: z.string().optional(), + tags: z.array(z.string()).optional(), + author: z.string().optional(), + authorUrl: z.string().url("Author URL must be a valid URL").optional(), + sourceUrl: z.string().url("Source URL must be a valid URL").optional(), +}) + +/** + * Component type validation + */ +export const marketplaceItemTypeSchema = z.enum(["mode", "prompt", "package", "mcp"] as const) + +/** + * Repository metadata schema + */ +export const repositoryMetadataSchema = baseMetadataSchema + +/** + * Component metadata schema + */ +export const componentMetadataSchema = baseMetadataSchema.extend({ + type: marketplaceItemTypeSchema, +}) + +/** + * External item reference schema + */ +export const externalItemSchema = z.object({ + type: marketplaceItemTypeSchema, + path: z.string().min(1, "Path is required"), +}) + +/** + * Package metadata schema + */ +export const packageMetadataSchema = componentMetadataSchema.extend({ + type: z.literal("package"), + items: z.array(externalItemSchema).optional(), +}) + +/** + * Validate parsed YAML against a schema + * @param data Data to validate + * @param schema Schema to validate against + * @returns Validated data + * @throws Error if validation fails + */ +export function validateMetadata(data: unknown, schema: z.ZodType): T { + try { + return schema.parse(data) + } catch (error) { + if (error instanceof z.ZodError) { + const issues = error.issues + .map((issue) => { + const path = issue.path.join(".") + // Format error messages to match expected format + if (issue.message === "Required") { + if (path === "name") { + return "name: Name is required" + } + return path ? `${path}: ${path.split(".").pop()} is required` : "Required field missing" + } + if (issue.code === "invalid_enum_value") { + return path ? `${path}: Invalid value "${issue.received}"` : `Invalid value "${issue.received}"` + } + return path ? `${path}: ${issue.message}` : issue.message + }) + .join("\n") + throw new Error(issues) + } + throw error + } +} + +/** + * Determine metadata type and validate + * @param data Data to validate + * @returns Validated metadata + * @throws Error if validation fails + */ +export function validateAnyMetadata(data: unknown) { + // Try to determine the type of metadata + if (typeof data === "object" && data !== null) { + const obj = data as Record + + if ("type" in obj) { + const type = obj.type + switch (type) { + case "package": + return validateMetadata(data, packageMetadataSchema) + case "mode": + case "mcp": + case "prompt": + case "role": + case "storage": + return validateMetadata(data, componentMetadataSchema) + default: + throw new Error(`Unknown component type: ${String(type)}`) + } + } else { + // No type field, assume repository metadata + return validateMetadata(data, repositoryMetadataSchema) + } + } + + throw new Error("Invalid metadata: must be an object") +} + +/** + * Schema for a single marketplace item parameter + */ +export const parameterSchema = z.record(z.string(), z.any()) + +/** + * Schema for a marketplace item + */ +export const marketplaceItemSchema = baseMetadataSchema.extend({ + id: z.string(), + type: marketplaceItemTypeSchema, + url: z.string(), + repoUrl: z.string(), + sourceName: z.string().optional(), + lastUpdated: z.string().optional(), + defaultBranch: z.string().optional(), + path: z.string().optional(), + items: z + .array( + z.object({ + type: marketplaceItemTypeSchema, + path: z.string(), + metadata: componentMetadataSchema.optional(), + lastUpdated: z.string().optional(), + matchInfo: z + .object({ + // Assuming MatchInfo is an object, adjust if needed + matched: z.boolean(), + matchReason: z + .object({ + nameMatch: z.boolean().optional(), + descriptionMatch: z.boolean().optional(), + tagMatch: z.boolean().optional(), + typeMatch: z.boolean().optional(), + hasMatchingSubcomponents: z.boolean().optional(), + }) + .optional(), + }) + .optional(), + }), + ) + .optional(), + matchInfo: z + .object({ + // Assuming MatchInfo is an object, adjust if needed + matched: z.boolean(), + matchReason: z + .object({ + nameMatch: z.boolean().optional(), + descriptionMatch: z.boolean().optional(), + tagMatch: z.boolean().optional(), + typeMatch: z.boolean().optional(), + hasMatchingSubcomponents: z.boolean().optional(), + }) + .optional(), + }) + .optional(), + parameters: z.record(z.string(), z.any()).optional(), +}) diff --git a/src/services/marketplace/types.ts b/src/services/marketplace/types.ts new file mode 100644 index 0000000000..9fa2bcb98d --- /dev/null +++ b/src/services/marketplace/types.ts @@ -0,0 +1,159 @@ +import { RocketConfig } from "config-rocket" + +/** + * Information about why an item matched search/filter criteria + */ +export interface MatchInfo { + matched: boolean + matchReason?: { + nameMatch?: boolean + descriptionMatch?: boolean + tagMatch?: boolean + typeMatch?: boolean + hasMatchingSubcomponents?: boolean + } +} + +/** + * Supported component types + */ +export type MarketplaceItemType = "mode" | "prompt" | "package" | "mcp" + +/** + * Base metadata interface + */ +export interface BaseMetadata { + id?: string + name: string + description: string + version: string + binaryUrl?: string + binaryHash?: string + tags?: string[] + author?: string + authorUrl?: string + sourceUrl?: string +} + +/** + * Repository root metadata + */ +export interface RepositoryMetadata extends BaseMetadata {} + +/** + * Component metadata with type + */ +export interface ComponentMetadata extends BaseMetadata { + type: MarketplaceItemType +} + +/** + * Package metadata with optional subcomponents + */ +export interface PackageMetadata extends ComponentMetadata { + type: "package" + items?: { + type: MarketplaceItemType + path: string + metadata?: ComponentMetadata + }[] +} + +/** + * Subcomponent metadata with parent reference + */ +export interface SubcomponentMetadata extends ComponentMetadata { + parentPackage: { + name: string + path: string + } +} + +/** + * Represents an individual parsed marketplace item + */ +export interface MarketplaceItem { + id: string + name: string + description: string + type: MarketplaceItemType + url: string + repoUrl: string + sourceName?: string + author?: string + authorUrl?: string + tags?: string[] + version: string + binaryUrl?: string + binaryHash?: string + lastUpdated?: string + sourceUrl?: string + defaultBranch?: string + path?: string // Add path to main item + items?: { + type: MarketplaceItemType + path: string + metadata?: ComponentMetadata + lastUpdated?: string + matchInfo?: MatchInfo // Add match information for subcomponents + }[] + matchInfo?: MatchInfo // Add match information for the package itself + config?: RocketConfig // Revert to using RocketConfig +} + +/** + * Represents a Git repository source for marketplace items + */ +export interface MarketplaceSource { + url: string + name?: string + enabled: boolean +} + +/** + * Represents a repository with its metadata and items + */ +export interface MarketplaceRepository { + metadata: RepositoryMetadata + items: MarketplaceItem[] + url: string + error?: string + defaultBranch?: string +} + +/** + * Utility type for metadata files with locale + */ +export type LocalizedMetadata = { + [locale: string]: T +} + +/** + * Options for localization handling + */ +export interface LocalizationOptions { + userLocale: string + fallbackLocale: string +} + +export interface InstallMarketplaceItemOptions { + /** + * Specify the target scope + * + * @default 'project' + */ + target?: "global" | "project" + /** + * Parameters provided by the user for configurable marketplace items + */ + parameters?: Record +} + +export interface RemoveInstalledMarketplaceItemOptions { + /** + * Specify the target scope + * + * @default 'project' + */ + target?: "global" | "project" +} diff --git a/src/services/marketplace/utils.ts b/src/services/marketplace/utils.ts new file mode 100644 index 0000000000..2ada345e55 --- /dev/null +++ b/src/services/marketplace/utils.ts @@ -0,0 +1,13 @@ +import * as vscode from "vscode" + +/** + * Gets the user's locale from VS Code environment + * @returns The user's locale code (e.g., 'en', 'fr') + */ +export function getUserLocale(): string { + // Get from VS Code API + const vscodeLocale = vscode.env.language + + // Extract just the language part (e.g., "en-US" -> "en") + return vscodeLocale.split("-")[0].toLowerCase() +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index cd1efbe983..34184fdca1 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -16,6 +16,8 @@ import { import { McpServer } from "./mcp" import { Mode } from "./modes" import { RouterModels } from "./api" +import { MarketplaceItem, MarketplaceSource } from "../services/marketplace/types" +import { FullInstallatedMetadata } from "../services/marketplace/InstalledMetadataManager" export type { ProviderSettingsEntry, ToolProgressStatus } @@ -74,13 +76,18 @@ export interface ExtensionMessage { | "indexingStatusUpdate" | "indexCleared" | "codebaseIndexConfig" + | "openMarketplaceInstallSidebarWithConfig" + | "repositoryRefreshComplete" text?: string + payload?: any // Add a generic payload for now, can refine later + // Expected payload for "openMarketplaceInstallSidebarWithConfig": { item: MarketplaceItem, config: RocketConfig | undefined } action?: | "chatButtonClicked" | "mcpButtonClicked" | "settingsButtonClicked" | "historyButtonClicked" | "promptsButtonClicked" + | "marketplaceButtonClicked" | "didBecomeVisible" | "focusInput" invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage" @@ -112,6 +119,8 @@ export interface ExtensionMessage { error?: string setting?: string value?: any + items?: MarketplaceItem[] + url?: string // For repositoryRefreshComplete } export type ExtensionState = Pick< @@ -215,6 +224,9 @@ export type ExtensionState = Pick< settingsImportedAt?: number historyPreviewCollapsed?: boolean autoCondenseContextPercent: number + marketplaceSources?: MarketplaceSource[] + marketplaceItems?: MarketplaceItem[] + marketplaceInstalledMetadata?: FullInstallatedMetadata } export type { ClineMessage, ClineAsk, ClineSay } diff --git a/src/shared/MarketplaceValidation.ts b/src/shared/MarketplaceValidation.ts new file mode 100644 index 0000000000..6c3fa86796 --- /dev/null +++ b/src/shared/MarketplaceValidation.ts @@ -0,0 +1,254 @@ +/** + * Shared validation utilities for marketplace sources + */ +import { MarketplaceSource } from "../services/marketplace/types" + +/** + * Error type for marketplace source validation + */ +export interface ValidationError { + field: string + message: string +} + +/** + * Checks if a URL is a valid Git repository URL + * @param url The URL to validate + * @returns True if the URL is a valid Git repository URL, false otherwise + */ +export function isValidGitRepositoryUrl(url: string): boolean { + // Trim the URL to remove any leading/trailing whitespace + const trimmedUrl = url.trim() + + // HTTPS pattern (GitHub, GitLab, Bitbucket, etc.) + // Examples: + // - https://github.com/username/repo + // - https://github.com/username/repo.git + // - https://gitlab.com/username/repo + // - https://bitbucket.org/username/repo + const httpsPattern = + /^https?:\/\/(?:[a-zA-Z0-9_-]+\.)*[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+(?:\/[^/]+)*(?:\.git)?$/ + + // SSH pattern + // Examples: + // - git@github.com:username/repo.git + // - git@gitlab.com:username/repo.git + const sshPattern = /^git@(?:[a-zA-Z0-9_-]+\.)*[a-zA-Z0-9_-]+:([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)(?:\.git)?$/ + + // Git protocol pattern + // Examples: + // - git://github.com/username/repo.git + const gitProtocolPattern = /^git:\/\/(?:[a-zA-Z0-9_-]+\.)*[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+(?:\.git)?$/ + + return httpsPattern.test(trimmedUrl) || sshPattern.test(trimmedUrl) || gitProtocolPattern.test(trimmedUrl) +} + +export function validateSourceUrl(url: string): ValidationError[] { + const errors: ValidationError[] = [] + + // Check if URL is empty + if (!url) { + errors.push({ + field: "url", + message: "URL cannot be empty", + }) + return errors // Return early if URL is empty + } + + // Check for non-visible characters (except spaces) + const nonVisibleCharRegex = /[^\S ]/ + if (nonVisibleCharRegex.test(url)) { + errors.push({ + field: "url", + message: "URL contains non-visible characters other than spaces", + }) + } + + // Check if URL is a valid Git repository URL + if (!isValidGitRepositoryUrl(url)) { + errors.push({ + field: "url", + message: "URL must be a valid Git repository URL (e.g., https://git.example.com/username/repo)", + }) + } + + return errors +} + +export function validateSourceName(name?: string): ValidationError[] { + const errors: ValidationError[] = [] + + // Skip validation if name is not provided + if (!name) { + return errors + } + + // Check name length + if (name.length > 20) { + errors.push({ + field: "name", + message: "Name must be 20 characters or less", + }) + } + + // Check for non-visible characters (except spaces) + const nonVisibleCharRegex = /[^\S ]/ + if (nonVisibleCharRegex.test(name)) { + errors.push({ + field: "name", + message: "Name contains non-visible characters other than spaces", + }) + } + + return errors +} + +// Cache for normalized strings to avoid repeated operations +const normalizeCache = new Map() + +function normalizeString(str: string): string { + const cached = normalizeCache.get(str) + if (cached) return cached + + const normalized = str.toLowerCase().replace(/\s+/g, "") + normalizeCache.set(str, normalized) + return normalized +} + +export function validateSourceDuplicates( + sources: MarketplaceSource[], + newSource?: MarketplaceSource, +): ValidationError[] { + const errors: ValidationError[] = [] + + // Process existing sources + const seen = new Set() + + // Check for duplicates within existing sources + for (let i = 0; i < sources.length; i++) { + const source = sources[i] + const normalizedUrl = normalizeString(source.url) + const normalizedName = source.name ? normalizeString(source.name) : null + + // Check for URL duplicates + for (let j = i + 1; j < sources.length; j++) { + const otherSource = sources[j] + const otherUrl = normalizeString(otherSource.url) + + if (normalizedUrl === otherUrl) { + const key = `url:${i}:${j}` + if (!seen.has(key)) { + errors.push({ + field: "url", + message: `Source #${i + 1} has a duplicate URL with Source #${j + 1}`, + }) + errors.push({ + field: "url", + message: `Source #${j + 1} has a duplicate URL with Source #${i + 1}`, + }) + seen.add(key) + seen.add(`url:${j}:${i}`) + } + } + + // Check for name duplicates if both have names + if (normalizedName && otherSource.name) { + const otherName = normalizeString(otherSource.name) + if (normalizedName === otherName) { + const key = `name:${i}:${j}` + if (!seen.has(key)) { + errors.push({ + field: "name", + message: `Source #${i + 1} has a duplicate name with Source #${j + 1}`, + }) + errors.push({ + field: "name", + message: `Source #${j + 1} has a duplicate name with Source #${i + 1}`, + }) + seen.add(key) + seen.add(`name:${j}:${i}`) + } + } + } + } + } + + // Check new source against existing sources if provided + if (newSource) { + const normalizedNewUrl = normalizeString(newSource.url) + const normalizedNewName = newSource.name ? normalizeString(newSource.name) : null + + // Check for duplicates with existing sources + for (let i = 0; i < sources.length; i++) { + const source = sources[i] + const sourceUrl = normalizeString(source.url) + + if (sourceUrl === normalizedNewUrl) { + errors.push({ + field: "url", + message: `URL is a duplicate of Source #${i + 1}`, + }) + } + + if (source.name && normalizedNewName) { + const sourceName = normalizeString(source.name) + if (sourceName === normalizedNewName) { + errors.push({ + field: "name", + message: `Name is a duplicate of Source #${i + 1}`, + }) + } + } + } + } + + return errors +} + +export function validateSource( + source: MarketplaceSource, + existingSources: MarketplaceSource[] = [], +): ValidationError[] { + // Combine all validation errors + return [ + ...validateSourceUrl(source.url), + ...validateSourceName(source.name), + ...validateSourceDuplicates(existingSources, source), + ] +} + +export function validateSources(sources: MarketplaceSource[]): ValidationError[] { + // Pre-allocate maximum possible size for errors array + const errors: ValidationError[] = new Array(sources.length * 2 + (sources.length * (sources.length - 1)) / 2) + let errorIndex = 0 + + // Validate each source individually + for (let i = 0; i < sources.length; i++) { + const source = sources[i] + const urlErrors = validateSourceUrl(source.url) + const nameErrors = validateSourceName(source.name) + + // Add index to error messages + for (const error of urlErrors) { + errors[errorIndex++] = { + field: error.field, + message: `Source #${i + 1}: ${error.message}`, + } + } + for (const error of nameErrors) { + errors[errorIndex++] = { + field: error.field, + message: `Source #${i + 1}: ${error.message}`, + } + } + } + + // Check for duplicates across all sources + const duplicateErrors = validateSourceDuplicates(sources) + for (const error of duplicateErrors) { + errors[errorIndex++] = error + } + + // Trim array to actual size + return errors.slice(0, errorIndex) +} diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 85a12aa238..5d95d28f44 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -2,6 +2,8 @@ import { z } from "zod" import { ProviderSettings } from "./api" import { Mode, PromptComponent, ModeConfig } from "./modes" +import { InstallMarketplaceItemOptions, MarketplaceItem, MarketplaceSource } from "../services/marketplace/types" +import { marketplaceItemSchema } from "../services/marketplace/schemas" export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" @@ -141,6 +143,19 @@ export interface WebviewMessage { | "indexingStatusUpdate" | "indexCleared" | "codebaseIndexConfig" + | "repositoryRefreshComplete" + | "setHistoryPreviewCollapsed" + | "openExternal" + | "marketplaceSources" + | "fetchMarketplaceItems" + | "filterMarketplaceItems" + | "marketplaceButtonClicked" + | "refreshMarketplaceSource" + | "installMarketplaceItem" + | "installMarketplaceItemWithParameters" + | "cancelMarketplaceInstall" + | "removeInstalledMarketplaceItem" + | "openMarketplaceInstallSidebarWithConfig" text?: string disabled?: boolean askResponse?: ClineAskResponse @@ -170,6 +185,12 @@ export interface WebviewMessage { hasSystemPromptOverride?: boolean terminalOperation?: "continue" | "abort" historyPreviewCollapsed?: boolean + sources?: MarketplaceSource[] + filters?: { type?: string; search?: string; tags?: string[] } + url?: string // For openExternal + mpItem?: MarketplaceItem + mpInstallOptions?: InstallMarketplaceItemOptions + config?: Record // Add config to the payload } export const checkoutDiffPayloadSchema = z.object({ @@ -199,8 +220,25 @@ export interface IndexClearedPayload { error?: string } +export const installMarketplaceItemWithParametersPayloadSchema = z.object({ + item: marketplaceItemSchema.strict(), + parameters: z.record(z.string(), z.any()), +}) + +export type InstallMarketplaceItemWithParametersPayload = z.infer< + typeof installMarketplaceItemWithParametersPayloadSchema +> + +export const cancelMarketplaceInstallPayloadSchema = z.object({ + itemId: z.string(), +}) + +export type CancelMarketplaceInstallPayload = z.infer + export type WebViewMessagePayload = | CheckpointDiffPayload | CheckpointRestorePayload | IndexingStatusPayload | IndexClearedPayload + | InstallMarketplaceItemWithParametersPayload + | CancelMarketplaceInstallPayload diff --git a/src/shared/__tests__/MarketplaceValidation.test.ts b/src/shared/__tests__/MarketplaceValidation.test.ts new file mode 100644 index 0000000000..206d428a4c --- /dev/null +++ b/src/shared/__tests__/MarketplaceValidation.test.ts @@ -0,0 +1,34 @@ +import { isValidGitRepositoryUrl } from "../MarketplaceValidation" + +describe("Git URL Validation", () => { + const validUrls = [ + "https://github.com/user/repo", + "https://gitlab.com/group/repo", + "https://git.internal.company.com/team/repo", + "git@github.com:user/repo.git", + "git@git.internal.company.com:team/repo", + "git://gitlab.com/group/repo.git", + "git://git.internal.company.com/team-name/repo-name", + "https://github.com/org-name/repo-name", + "git@gitlab.com:group-name/project-name.git", + ] + + const invalidUrls = [ + "not-a-url", + "http://single/repo", + "https://github.com/no-repo", + "git@github.com/wrong-format", + "git://invalid@domain:repo", + "git@domain:no-slash", + "git@domain:/invalid-start", + "git@domain:group//repo", + ] + + test.each(validUrls)("should accept valid git URL: %s", (url) => { + expect(isValidGitRepositoryUrl(url)).toBe(true) + }) + + test.each(invalidUrls)("should reject invalid git URL: %s", (url) => { + expect(isValidGitRepositoryUrl(url)).toBe(false) + }) +}) diff --git a/src/utils/globalContext.ts b/src/utils/globalContext.ts new file mode 100644 index 0000000000..882501850d --- /dev/null +++ b/src/utils/globalContext.ts @@ -0,0 +1,13 @@ +import { mkdir } from "fs/promises" +import { join } from "path" +import { ExtensionContext } from "vscode" + +export async function getGlobalFsPath(context: ExtensionContext): Promise { + return context.globalStorageUri.fsPath +} + +export async function ensureSettingsDirectoryExists(context: ExtensionContext): Promise { + const settingsDir = join(context.globalStorageUri.fsPath, "settings") + await mkdir(settingsDir, { recursive: true }) + return settingsDir +} diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000000..1e6f5627e0 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,8 @@ +export const isValidUrl = (urlString: string): boolean => { + try { + new URL(urlString) + return true + } catch (e) { + return false + } +} diff --git a/webview-ui/package.json b/webview-ui/package.json index cb9e5c91e3..7d0854dae6 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -15,6 +15,7 @@ "clean": "rimraf build tsconfig.tsbuildinfo .turbo" }, "dependencies": { + "@radix-ui/react-accordion": "^1.2.10", "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-checkbox": "^1.1.5", "@radix-ui/react-collapsible": "^1.1.3", @@ -59,6 +60,7 @@ "rehype-highlight": "^7.0.0", "remark-gfm": "^4.0.1", "remove-markdown": "^0.6.0", + "rocket-config": "^1.0.7", "shell-quote": "^1.8.2", "shiki": "^3.2.1", "source-map": "^0.7.4", diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 8ca72ecf71..c0375dcde4 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState, useMemo } from "react" import { useEvent } from "react-use" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ExtensionMessage } from "@roo/shared/ExtensionMessage" import TranslationProvider from "./i18n/TranslationContext" +import { MarketplaceViewStateManager } from "./components/marketplace/MarketplaceViewStateManager" import { vscode } from "./utils/vscode" import { telemetryClient } from "./utils/TelemetryClient" @@ -13,10 +14,11 @@ import HistoryView from "./components/history/HistoryView" import SettingsView, { SettingsViewRef } from "./components/settings/SettingsView" import WelcomeView from "./components/welcome/WelcomeView" import McpView from "./components/mcp/McpView" +import { MarketplaceView } from "./components/marketplace/MarketplaceView" import PromptsView from "./components/prompts/PromptsView" import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog" -type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" +type Tab = "settings" | "history" | "mcp" | "prompts" | "chat" | "marketplace" const tabsByMessageAction: Partial, Tab>> = { chatButtonClicked: "chat", @@ -24,12 +26,16 @@ const tabsByMessageAction: Partial { const { didHydrateState, showWelcome, shouldShowAnnouncement, telemetrySetting, telemetryKey, machineId } = useExtensionState() + // Create a persistent state manager + const marketplaceStateManager = useMemo(() => new MarketplaceViewStateManager(), []) + const [showAnnouncement, setShowAnnouncement] = useState(false) const [tab, setTab] = useState("chat") @@ -118,6 +124,9 @@ const App = () => { {tab === "settings" && ( setTab("chat")} targetSection={currentSection} /> )} + {tab === "marketplace" && ( + switchTab("chat")} /> + )} React.createElement("div") export const X = () => React.createElement("div") export const Edit = () => React.createElement("div") export const Database = (props: any) => React.createElement("span", { "data-testid": "database-icon", ...props }) +export const MoreVertical = () => React.createElement("div", {}, "VerticalMenu") +export const ExternalLink = () => React.createElement("div") +export const Download = () => React.createElement("div") diff --git a/webview-ui/src/components/marketplace/InstallSidebar.tsx b/webview-ui/src/components/marketplace/InstallSidebar.tsx new file mode 100644 index 0000000000..d25d574986 --- /dev/null +++ b/webview-ui/src/components/marketplace/InstallSidebar.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react" +import { VSCodeButton, VSCodeTextField, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import { MarketplaceItem } from "../../../../src/services/marketplace/types" +import { RocketConfig } from "config-rocket" + +interface MarketplaceInstallSidebarProps { + item: MarketplaceItem + config: RocketConfig + onClose?: () => void + onSubmit?: (item: MarketplaceItem, parameters: Record) => void +} + +const InstallSidebar: React.FC = ({ item, config, onClose, onSubmit }) => { + const initialUserParameters = config.parameters!.reduce( + (acc, param) => { + if (param.resolver.operation === "prompt") + acc[param.id] = param.resolver.initial ?? (param.resolver.type === "confirm" ? true : "") + + return acc + }, + {} as Record, + ) + const [userParameters, setUserParameters] = useState>(initialUserParameters) + + const handleParameterChange = (name: string, value: any) => { + setUserParameters({ ...userParameters, [name]: value }) + } + + const handleSubmit = () => { + if (onSubmit && item) { + onSubmit(item, userParameters) + } + } + + return ( +
+
e.stopPropagation()}> +

Install {item.name}

+
+ {config.parameters?.map((param) => { + // Only render prompt parameters + if (param.resolver.operation !== "prompt") return null + + return ( +
+ + {/* Render input based on param.resolver.type */} + {param.resolver.type === "text" && ( + + handleParameterChange(param.id, (e.target as HTMLInputElement).value) + } + className="w-full"> + )} + {param.resolver.type === "confirm" && ( + + handleParameterChange(param.id, (e.target as HTMLInputElement).checked) + }> + )} +
+ ) + })} +
+
+ + Install + + + Cancel + +
+
+
+ ) +} + +export default InstallSidebar diff --git a/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/webview-ui/src/components/marketplace/MarketplaceListView.tsx new file mode 100644 index 0000000000..4f5198530d --- /dev/null +++ b/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -0,0 +1,333 @@ +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Button } from "@/components/ui/button" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" +import { Clock, X, ChevronsUpDown, Rocket, Server, Package, Sparkles, ALargeSmall } from "lucide-react" +import { MarketplaceItemCard } from "./components/MarketplaceItemCard" +import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { useStateManager } from "./useStateManager" + +export interface MarketplaceListViewProps { + stateManager: MarketplaceViewStateManager + allTags: string[] + filteredTags: string[] + tagSearch: string + setTagSearch: (value: string) => void + isTagPopoverOpen: boolean + setIsTagPopoverOpen: (value: boolean) => void +} + +export function MarketplaceListView({ + stateManager, + allTags, + filteredTags, + tagSearch, + setTagSearch, + isTagPopoverOpen, + setIsTagPopoverOpen, +}: MarketplaceListViewProps) { + const [state, manager] = useStateManager(stateManager) + const { t } = useAppTranslation() + const items = state.displayItems || [] + const isEmpty = items.length === 0 + + return ( + <> +
+
+ + manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters: { search: e.target.value } }, + }) + } + /> +
+
+
+
+ + +
+
+ +
+ + +
+
+
+ + {allTags.length > 0 && ( +
+
+
+ + ({allTags.length}) +
+ {state.filters.tags.length > 0 && ( + + )} +
+ + + + + + + +
+ + {tagSearch && ( + + )} +
+ + + {t("marketplace:filters.tags.noResults")} + + + {filteredTags.map((tag: string) => ( + { + const isSelected = state.filters.tags.includes(tag) + manager.transition({ + type: "UPDATE_FILTERS", + payload: { + filters: { + tags: isSelected + ? state.filters.tags.filter( + (t) => t !== tag, + ) + : [...state.filters.tags, tag], + }, + }, + }) + }} + data-selected={state.filters.tags.includes(tag)} + className="grid grid-cols-[1rem_1fr] gap-2 cursor-pointer text-sm capitalize" + onMouseDown={(e) => e.preventDefault()}> + {state.filters.tags.includes(tag) ? ( + + ) : ( + + )} + {tag} + + ))} + + +
+
+
+ {state.filters.tags.length > 0 && ( +
+ + {t("marketplace:filters.tags.selected", { + count: state.filters.tags.length, + })} +
+ )} +
+ )} +
+
+ + {state.isFetching && ( +
+
+ +
+

{t("marketplace:items.refresh.refreshing")}

+

This may take a moment...

+
+ )} + + {!state.isFetching && isEmpty && ( +
+ +

{t("marketplace:items.empty.noItems")}

+

Try adjusting your filters or search terms

+ +
+ )} + + {!state.isFetching && !isEmpty && ( +
+

+ + {t("marketplace:items.count", { count: items.length })} +

+
+ {items.map((item) => ( + + manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters }, + }) + } + activeTab={state.activeTab} + setActiveTab={(tab) => + manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab }, + }) + } + /> + ))} +
+
+ )} + + ) +} diff --git a/webview-ui/src/components/marketplace/MarketplaceSourcesConfigView.tsx b/webview-ui/src/components/marketplace/MarketplaceSourcesConfigView.tsx new file mode 100644 index 0000000000..2ef4bcbc46 --- /dev/null +++ b/webview-ui/src/components/marketplace/MarketplaceSourcesConfigView.tsx @@ -0,0 +1,237 @@ +import { MarketplaceSource } from "../../../../src/services/marketplace/types" +import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Checkbox } from "@/components/ui/checkbox" +import { useStateManager } from "./useStateManager" +import { validateSource } from "@roo/shared/MarketplaceValidation" +import { cn } from "@src/lib/utils" + +export interface MarketplaceSourcesConfigProps { + stateManager: MarketplaceViewStateManager +} + +export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesConfigProps) { + const { t } = useAppTranslation() + const [state, manager] = useStateManager(stateManager) + const [newSourceUrl, setNewSourceUrl] = useState("") + const [newSourceName, setNewSourceName] = useState("") + const [error, setError] = useState("") + + const handleAddSource = () => { + const MAX_SOURCES = 10 + if (state.sources.length >= MAX_SOURCES) { + setError(t("marketplace:sources.errors.maxSources", { max: MAX_SOURCES })) + return + } + const sourceToValidate: MarketplaceSource = { + url: newSourceUrl, + name: newSourceName || undefined, + enabled: true, + } + const validationErrors = validateSource(sourceToValidate, state.sources) + if (validationErrors.length > 0) { + const errorMessages: Record = { + "url:empty": "marketplace:sources.errors.emptyUrl", + "url:nonvisible": "marketplace:sources.errors.nonVisibleChars", + "url:invalid": "marketplace:sources.errors.invalidGitUrl", + "url:duplicate": "marketplace:sources.errors.duplicateUrl", + "name:length": "marketplace:sources.errors.nameTooLong", + "name:nonvisible": "marketplace:sources.errors.nonVisibleCharsName", + "name:duplicate": "marketplace:sources.errors.duplicateName", + } + const error = validationErrors[0] + const errorKey = `${error.field}:${error.message.toLowerCase().split(" ")[0]}` + setError(t(errorMessages[errorKey] || "marketplace:sources.errors.invalidGitUrl")) + return + } + manager.transition({ + type: "UPDATE_SOURCES", + payload: { sources: [...state.sources, sourceToValidate] }, + }) + setNewSourceUrl("") + setNewSourceName("") + setError("") + } + + const handleToggleSource = useCallback( + (index: number) => { + manager.transition({ + type: "UPDATE_SOURCES", + payload: { + sources: state.sources.map((source, i) => + i === index ? { ...source, enabled: !source.enabled } : source, + ), + }, + }) + }, + [state.sources, manager], + ) + + const handleRemoveSource = useCallback( + (index: number) => { + manager.transition({ + type: "UPDATE_SOURCES", + payload: { + sources: state.sources.filter((_, i) => i !== index), + }, + }) + }, + [state.sources, manager], + ) + + return ( +
+

{t("marketplace:sources.title")}

+

{t("marketplace:sources.description")}

+ +
+
+
+ { + setNewSourceName(e.target.value.slice(0, 20)) + setError("") + }} + maxLength={20} + className="pl-10" + /> + + + + + {newSourceName.length}/20 + +
+
+ { + setNewSourceUrl(e.target.value) + setError("") + }} + className="pl-10" + /> + + + +
+

+ {t("marketplace:sources.add.urlFormats")} +

+
+ {error && ( +
+

+ + {error} +

+
+ )} + +
+ +
+
+ + + {t("marketplace:sources.current.title")} + +
+ + {state.sources.length} / 10 + +
+ + {state.sources.length === 0 ? ( +
+ +

{t("marketplace:sources.current.empty")}

+

{t("marketplace:sources.current.emptyHint")}

+
+ ) : ( +
+ {state.sources.map((source, index) => ( +
+
+
+
+ handleToggleSource(index)} + variant="description" + /> +
+
+

+ {source.name || source.url} +

+ {source.name && ( +

+ {source.url} +

+ )} +
+
+
+
+ + + +
+
+ ))} +
+ )} +
+ ) +} diff --git a/webview-ui/src/components/marketplace/MarketplaceView.tsx b/webview-ui/src/components/marketplace/MarketplaceView.tsx new file mode 100644 index 0000000000..7c00870c15 --- /dev/null +++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect, useMemo, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Tab, TabContent, TabHeader } from "../common/Tab" +import { MarketplaceItem } from "../../../../src/services/marketplace/types" +import { MarketplaceViewStateManager } from "./MarketplaceViewStateManager" +import { useStateManager } from "./useStateManager" +import { useAppTranslation } from "@/i18n/TranslationContext" +import InstallSidebar from "./InstallSidebar" +import { useEvent } from "react-use" +import { ExtensionMessage } from "@roo/shared/ExtensionMessage" +import { vscode } from "@/utils/vscode" +import { RocketConfig } from "config-rocket" +import { MarketplaceSourcesConfig } from "./MarketplaceSourcesConfigView" +import { MarketplaceListView } from "./MarketplaceListView" +import { cn } from "@/lib/utils" +import { Package, RefreshCw, Server } from "lucide-react" +import { TooltipProvider } from "@/components/ui/tooltip" + +interface MarketplaceViewProps { + onDone?: () => void + stateManager: MarketplaceViewStateManager +} +export function MarketplaceView({ stateManager }: MarketplaceViewProps) { + const { t } = useAppTranslation() + const [state, manager] = useStateManager(stateManager) + + const [tagSearch, setTagSearch] = useState("") + const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false) + const [showInstallSidebar, setShowInstallSidebar] = useState< + | { + item: MarketplaceItem + config: RocketConfig + } + | false + >(false) + + const handleInstallSubmit = (item: MarketplaceItem, parameters: Record) => { + vscode.postMessage({ + type: "installMarketplaceItemWithParameters", + payload: { item, parameters }, + }) + setShowInstallSidebar(false) + } + + const onMessage = useCallback( + (e: MessageEvent) => { + const message: ExtensionMessage = e.data + if (message.type === "openMarketplaceInstallSidebarWithConfig") { + setShowInstallSidebar({ item: message.payload.item, config: message.payload.config }) + } + }, + [setShowInstallSidebar], + ) + + useEvent("message", onMessage) + + // Listen for panel visibility events to fetch data when panel becomes visible + useEffect(() => { + const handleVisibilityMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "webviewVisible" && message.visible === true) { + // Fetch items when panel becomes visible and we're on browse tab + if (state.activeTab === "browse" && !state.isFetching) { + manager.transition({ type: "FETCH_ITEMS" }) + } + } + } + + window.addEventListener("message", handleVisibilityMessage) + return () => window.removeEventListener("message", handleVisibilityMessage) + }, [manager, state.activeTab, state.isFetching]) + + // Fetch items on first mount or when returning to empty state + useEffect(() => { + if (!state.allItems.length && !state.isFetching) { + manager.transition({ type: "FETCH_ITEMS" }) + } + }, [manager, state.allItems.length, state.isFetching]) + + // Memoize all available tags + const allTags = useMemo( + () => Array.from(new Set(state.allItems.flatMap((item) => item.tags || []))).sort(), + [state.allItems], + ) + + // Memoize filtered tags + const filteredTags = useMemo( + () => + tagSearch ? allTags.filter((tag: string) => tag.toLowerCase().includes(tagSearch.toLowerCase())) : allTags, + [allTags, tagSearch], + ) + + return ( + + + +
+

{t("marketplace:title")}

+ +
+
+
+
+ + +
+
+ + + +
+
+ +
+ +
+ +
+
+
+ + + {showInstallSidebar && ( +
+
+ setShowInstallSidebar(false)} + onSubmit={handleInstallSubmit} + item={showInstallSidebar.item} + config={showInstallSidebar.config} + /> +
+
+ )} + + ) +} diff --git a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts new file mode 100644 index 0000000000..2fb76c68ab --- /dev/null +++ b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts @@ -0,0 +1,613 @@ +/** + * MarketplaceViewStateManager + * + * This class manages the state for the marketplace view in the Roo Code extensions interface. + * + * IMPORTANT: Fixed issue where the marketplace feature was causing the Roo Code extensions interface + * to switch to the browse tab and redraw it every 30 seconds. The fix prevents unnecessary tab switching + * and redraws by: + * 1. Only updating the UI when necessary + * 2. Preserving the current tab when handling timeouts + * 3. Using minimal state updates to avoid resetting scroll position + */ + +import { MarketplaceItem, MarketplaceSource, MatchInfo } from "../../../../src/services/marketplace/types" +import { vscode } from "../../utils/vscode" +import { WebviewMessage } from "../../../../src/shared/WebviewMessage" +import { DEFAULT_MARKETPLACE_SOURCE } from "../../../../src/services/marketplace/constants" +import { FullInstallatedMetadata } from "../../../../src/services/marketplace/InstalledMetadataManager" + +export interface ViewState { + allItems: MarketplaceItem[] + displayItems?: MarketplaceItem[] // Items currently being displayed (filtered or all) + isFetching: boolean + activeTab: "browse" | "sources" + refreshingUrls: string[] + sources: MarketplaceSource[] + installedMetadata: FullInstallatedMetadata + filters: { + type: string + search: string + tags: string[] + } + sortConfig: { + by: "name" | "author" | "lastUpdated" + order: "asc" | "desc" + } +} + +// Define a default empty metadata structure +const defaultInstalledMetadata: FullInstallatedMetadata = { + project: {}, + global: {}, +} + +type TransitionPayloads = { + FETCH_ITEMS: undefined + FETCH_COMPLETE: { items: MarketplaceItem[] } + FETCH_ERROR: undefined + SET_ACTIVE_TAB: { tab: ViewState["activeTab"] } + UPDATE_FILTERS: { filters: Partial } + UPDATE_SORT: { sortConfig: Partial } + REFRESH_SOURCE: { url: string } + REFRESH_SOURCE_COMPLETE: { url: string } + UPDATE_SOURCES: { sources: MarketplaceSource[] } +} + +export interface ViewStateTransition { + type: keyof TransitionPayloads + payload?: TransitionPayloads[keyof TransitionPayloads] +} + +export type StateChangeHandler = (state: ViewState) => void + +export class MarketplaceViewStateManager { + private state: ViewState = this.loadInitialState() + + private loadInitialState(): ViewState { + // Try to restore state from sessionStorage if available + if (typeof sessionStorage !== "undefined") { + const savedState = sessionStorage.getItem("marketplaceState") + if (savedState) { + try { + return JSON.parse(savedState) + } catch { + return this.getDefaultState() + } + } + } + return this.getDefaultState() + } + + private getDefaultState(): ViewState { + return { + allItems: [], + displayItems: [] as MarketplaceItem[], + isFetching: false, + activeTab: "browse", + refreshingUrls: [], + sources: [DEFAULT_MARKETPLACE_SOURCE], + installedMetadata: defaultInstalledMetadata, + filters: { + type: "", + search: "", + tags: [], + }, + sortConfig: { + by: "name", + order: "asc", + }, + } + } + // Removed auto-polling timeout + private stateChangeHandlers: Set = new Set() + + // Empty constructor is required for test initialization + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor() { + // Initialize is now handled by the loadInitialState call in the property initialization + } + + public initialize(): void { + // Set initial state + this.state = this.getDefaultState() + + // Send initial sources to extension + vscode.postMessage({ + type: "marketplaceSources", + sources: [DEFAULT_MARKETPLACE_SOURCE], + } as WebviewMessage) + } + + public onStateChange(handler: StateChangeHandler): () => void { + this.stateChangeHandlers.add(handler) + return () => this.stateChangeHandlers.delete(handler) + } + + public cleanup(): void { + // Reset fetching state + if (this.state.isFetching) { + this.state.isFetching = false + this.notifyStateChange() + } + + // Clear handlers but preserve state + this.stateChangeHandlers.clear() + } + + public getState(): ViewState { + // Only create new arrays if they exist and have items + const allItems = this.state.allItems.length ? [...this.state.allItems] : [] + const displayItems = this.state.displayItems?.length ? [...this.state.displayItems] : this.state.displayItems + const refreshingUrls = this.state.refreshingUrls.length ? [...this.state.refreshingUrls] : [] + const tags = this.state.filters.tags.length ? [...this.state.filters.tags] : [] + const sources = this.state.sources.length ? [...this.state.sources] : [DEFAULT_MARKETPLACE_SOURCE] + const installedMetadata = this.state.installedMetadata + + // Create minimal new state object + return { + ...this.state, + allItems, + displayItems, + refreshingUrls, + sources, + installedMetadata, + filters: { + ...this.state.filters, + tags, + }, + } + } + + /** + * Notify all registered handlers of a state change + * @param preserveTab If true, ensures the active tab is not changed during notification + */ + private notifyStateChange(preserveTab: boolean = false): void { + const newState = this.getState() // Use getState to ensure proper copying + + if (preserveTab) { + // When preserveTab is true, we're careful not to cause tab switching + // This is used during timeout handling to prevent disrupting the user + this.stateChangeHandlers.forEach((handler) => { + // Store the current active tab + const currentTab = newState.activeTab + + // Create a state update that won't change the active tab + const safeState = { + ...newState, + // Don't change these properties to avoid UI disruption + activeTab: currentTab, + } + handler(safeState) + }) + } else { + // Normal state change notification + this.stateChangeHandlers.forEach((handler) => { + handler(newState) + }) + } + + // Save state to sessionStorage if available + if (typeof sessionStorage !== "undefined") { + try { + sessionStorage.setItem("marketplaceState", JSON.stringify(this.state)) + } catch (error) { + console.warn("Failed to save marketplace state:", error) + } + } + } + + public async transition(transition: ViewStateTransition): Promise { + switch (transition.type) { + case "FETCH_ITEMS": { + // Don't start a new fetch if one is in progress + if (this.state.isFetching) { + return + } + + // Send fetch request + vscode.postMessage({ + type: "fetchMarketplaceItems", + } as WebviewMessage) + + // Store current items before updating state + const currentItems = this.state.allItems.length ? [...this.state.allItems] : [] + + // Update state after sending request + this.state = { + ...this.state, + isFetching: true, + allItems: currentItems, + displayItems: currentItems, + } + this.notifyStateChange() + + break + } + + case "FETCH_COMPLETE": { + const { items } = transition.payload as TransitionPayloads["FETCH_COMPLETE"] + // No timeout to clear anymore + + // Sort incoming items + const sortedItems = this.sortItems([...items]) + + // Compare with current state to avoid unnecessary updates + const currentSortedItems = this.sortItems([...this.state.allItems]) + if (JSON.stringify(sortedItems) === JSON.stringify(currentSortedItems)) { + // No changes: update only isFetching flag and send minimal update + this.state.isFetching = false + this.stateChangeHandlers.forEach((handler) => { + handler({ + ...this.getState(), + isFetching: false, + }) + }) + break + } + + // Update allItems as source of truth + this.state = { + ...this.state, + allItems: sortedItems, + displayItems: this.isFilterActive() ? this.filterItems(sortedItems) : sortedItems, + isFetching: false, + } + + // Notify state change + this.notifyStateChange() + break + } + + case "FETCH_ERROR": { + // Preserve current filters and sources + const { filters, sources, activeTab } = this.state + + // Reset state but preserve filters and sources + this.state = { + ...this.getDefaultState(), + filters, + sources, + activeTab, + isFetching: false, + } + this.notifyStateChange() + break + } + + case "SET_ACTIVE_TAB": { + const { tab } = transition.payload as TransitionPayloads["SET_ACTIVE_TAB"] + + // Update tab state + this.state = { + ...this.state, + activeTab: tab, + } + + // If switching to browse tab, trigger fetch + if (tab === "browse") { + this.state.isFetching = true + + vscode.postMessage({ + type: "fetchMarketplaceItems", + } as WebviewMessage) + } + + this.notifyStateChange() + break + } + + case "UPDATE_FILTERS": { + const { filters = {} } = (transition.payload as TransitionPayloads["UPDATE_FILTERS"]) || {} + + // Create new filters object preserving existing values for undefined fields + const updatedFilters = { + type: filters.type !== undefined ? filters.type : this.state.filters.type, + search: filters.search !== undefined ? filters.search : this.state.filters.search, + tags: filters.tags !== undefined ? filters.tags : this.state.filters.tags, + } + + // Update state + this.state = { + ...this.state, + filters: updatedFilters, + } + + // Send filter message + vscode.postMessage({ + type: "filterMarketplaceItems", + filters: updatedFilters, + } as WebviewMessage) + + this.notifyStateChange() + + break + } + + case "UPDATE_SORT": { + const { sortConfig } = transition.payload as TransitionPayloads["UPDATE_SORT"] + // Create new state with updated sort config + this.state = { + ...this.state, + sortConfig: { + ...this.state.sortConfig, + ...sortConfig, + }, + } + // Apply sorting to both allItems and displayItems + // Sort items immutably + // Create new sorted arrays + const sortedAllItems = this.sortItems([...this.state.allItems]) + const sortedDisplayItems = this.state.displayItems?.length + ? this.sortItems([...this.state.displayItems]) + : this.state.displayItems + + this.state = { + ...this.state, + allItems: sortedAllItems, + displayItems: sortedDisplayItems, + } + this.notifyStateChange() + break + } + + case "REFRESH_SOURCE": { + const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE"] + if (!this.state.refreshingUrls.includes(url)) { + this.state = { + ...this.state, + refreshingUrls: [...this.state.refreshingUrls, url], + } + this.notifyStateChange() + vscode.postMessage({ + type: "refreshMarketplaceSource", + url, + } as WebviewMessage) + } + break + } + + case "REFRESH_SOURCE_COMPLETE": { + const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE_COMPLETE"] + this.state = { + ...this.state, + refreshingUrls: this.state.refreshingUrls.filter((existingUrl) => existingUrl !== url), + } + this.notifyStateChange() + break + } + + case "UPDATE_SOURCES": { + const { sources } = transition.payload as TransitionPayloads["UPDATE_SOURCES"] + // If all sources are removed, add the default source + const updatedSources = sources.length === 0 ? [DEFAULT_MARKETPLACE_SOURCE] : [...sources] + + this.state = { + ...this.state, + sources: updatedSources, + isFetching: false, // Reset fetching state + } + + this.notifyStateChange() + + // Send sources update to extension + vscode.postMessage({ + type: "marketplaceSources", + sources: updatedSources, + } as WebviewMessage) + + // If we're on the browse tab, trigger a fetch + if (this.state.activeTab === "browse") { + this.state.isFetching = true + this.notifyStateChange() + + vscode.postMessage({ + type: "fetchMarketplaceItems", + } as WebviewMessage) + } + break + } + } + } + + public isFilterActive(): boolean { + return !!(this.state.filters.type || this.state.filters.search || this.state.filters.tags.length > 0) + } + + public filterItems(items: MarketplaceItem[]): MarketplaceItem[] { + const { type, search, tags } = this.state.filters + + return items + .map((item) => { + // Create a copy of the item to modify + const itemCopy = { ...item } + + // Check specific match conditions for the main item + const typeMatch = !type || item.type === type + const nameMatch = search ? item.name.toLowerCase().includes(search.toLowerCase()) : false + const descriptionMatch = search + ? (item.description || "").toLowerCase().includes(search.toLowerCase()) + : false + const tagMatch = tags.length > 0 ? item.tags?.some((tag) => tags.includes(tag)) : false + + // Determine if the main item matches all filters + const mainItemMatches = + typeMatch && (!search || nameMatch || descriptionMatch) && (!tags.length || tagMatch) + + // For packages, check and mark matching subcomponents + if (item.type === "package" && item.items?.length) { + itemCopy.items = item.items.map((subItem) => { + // Check specific match conditions for subitem + const subTypeMatch = !type || subItem.type === type + const subNameMatch = + search && subItem.metadata + ? subItem.metadata.name.toLowerCase().includes(search.toLowerCase()) + : false + const subDescriptionMatch = + search && subItem.metadata + ? subItem.metadata.description.toLowerCase().includes(search.toLowerCase()) + : false + const subTagMatch = + tags.length > 0 ? Boolean(subItem.metadata?.tags?.some((tag) => tags.includes(tag))) : false + + const subItemMatches = + subTypeMatch && + (!search || subNameMatch || subDescriptionMatch) && + (!tags.length || subTagMatch) + + // Ensure all match properties are booleans + const matchInfo: MatchInfo = { + matched: Boolean(subItemMatches), + matchReason: subItemMatches + ? { + typeMatch: Boolean(subTypeMatch), + nameMatch: Boolean(subNameMatch), + descriptionMatch: Boolean(subDescriptionMatch), + tagMatch: Boolean(subTagMatch), + } + : undefined, + } + + return { + ...subItem, + matchInfo, + } + }) + } + + const hasMatchingSubcomponents = itemCopy.items?.some((subItem) => subItem.matchInfo?.matched) + + // Set match info on the main item + itemCopy.matchInfo = { + matched: mainItemMatches || Boolean(hasMatchingSubcomponents), + matchReason: { + typeMatch, + nameMatch, + descriptionMatch, + tagMatch, + hasMatchingSubcomponents: Boolean(hasMatchingSubcomponents), + }, + } + + // Return the item if it matches or has matching subcomponents + if (itemCopy.matchInfo.matched) { + return itemCopy + } + + return null + }) + .filter((item): item is MarketplaceItem => item !== null) + } + + private sortItems(items: MarketplaceItem[]): MarketplaceItem[] { + const { by, order } = this.state.sortConfig + const itemsCopy = [...items] + + return itemsCopy.sort((a, b) => { + const aValue = by === "lastUpdated" ? a[by] || "1970-01-01T00:00:00Z" : a[by] || "" + const bValue = by === "lastUpdated" ? b[by] || "1970-01-01T00:00:00Z" : b[by] || "" + + return order === "asc" ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue) + }) + } + + public async handleMessage(message: any): Promise { + // Handle empty or invalid message + if (!message || !message.type || message.type === "invalidType") { + const { sources } = this.state + this.state = { + ...this.getDefaultState(), + sources: [...sources], + } + this.notifyStateChange() + return + } + + // Handle state updates + if (message.type === "state") { + // Handle empty state + if (!message.state) { + const { sources } = this.state + this.state = { + ...this.getDefaultState(), + sources: [...sources], + } + this.notifyStateChange() + return + } + + // Update sources if present + const sources = message.state.marketplaceSources || message.state.sources + if (sources) { + this.state = { + ...this.state, + sources: sources.length > 0 ? [...sources] : [DEFAULT_MARKETPLACE_SOURCE], + } + // Don't notify yet, combine with other state updates below + } + + // Update installedMetadata if present + const installedMetadata = message.state.marketplaceInstalledMetadata + if (installedMetadata) { + this.state = { + ...this.state, + installedMetadata, + } + // Don't notify yet + } + + // Handle state updates for marketplace items + // The state.marketplaceItems come from ClineProvider, see the file src/core/webview/ClineProvider.ts + const marketplaceItems = message.state.marketplaceItems + if (marketplaceItems !== undefined) { + const currentItems = this.state.allItems || [] + const hasNewItems = marketplaceItems.length > 0 + const hasCurrentItems = currentItems.length > 0 + const isOnBrowseTab = this.state.activeTab === "browse" + + // Determine which items to use + const itemsToUse = hasNewItems ? marketplaceItems : isOnBrowseTab && hasCurrentItems ? currentItems : [] + const sortedItems = this.sortItems([...itemsToUse]) + const newDisplayItems = this.isFilterActive() ? this.filterItems(sortedItems) : sortedItems + + // Update state in a single operation + this.state = { + ...this.state, + isFetching: false, + allItems: sortedItems, + displayItems: newDisplayItems, + } + // Notification is handled below after all state parts are processed + } + + // Notify state change once after processing all parts (sources, metadata, items) + // This prevents multiple redraws for a single 'state' message + // Determine if notification should preserve tab based on item update logic + const isOnBrowseTab = this.state.activeTab === "browse" + const hasCurrentItems = (this.state.allItems || []).length > 0 + const preserveTab = !isOnBrowseTab && hasCurrentItems + + this.notifyStateChange(preserveTab) + } + + // Handle repository refresh completion + if (message.type === "repositoryRefreshComplete" && message.url) { + void this.transition({ + type: "REFRESH_SOURCE_COMPLETE", + payload: { url: message.url }, + }) + } + + // Handle marketplace button clicks + if (message.type === "marketplaceButtonClicked") { + if (message.text) { + // Error case + void this.transition({ type: "FETCH_ERROR" }) + } else { + // Refresh request + void this.transition({ type: "FETCH_ITEMS" }) + } + } + } +} diff --git a/webview-ui/src/components/marketplace/__tests__/InstallSidebar.test.tsx b/webview-ui/src/components/marketplace/__tests__/InstallSidebar.test.tsx new file mode 100644 index 0000000000..ff99d4cf58 --- /dev/null +++ b/webview-ui/src/components/marketplace/__tests__/InstallSidebar.test.tsx @@ -0,0 +1,159 @@ +import { fireEvent, screen } from "@testing-library/react" +import InstallSidebar from "../InstallSidebar" +import { MarketplaceItem } from "../../../../../src/services/marketplace/types" +import { RocketConfig } from "config-rocket" +import { renderWithProviders } from "@/test/test-utils" + +// Mock VSCode components +jest.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeButton: ({ children, onClick, appearance }: any) => ( + + ), + VSCodeTextField: ({ id, value, onChange }: any) => ( + onChange({ target: e.target })} + data-testid={`text-${id}`} + /> + ), + VSCodeCheckbox: ({ id, checked, onChange }: any) => ( + onChange({ target: e.target })} + data-testid={`checkbox-${id}`} + /> + ), +})) + +describe("InstallSidebar", () => { + const mockItem: MarketplaceItem = { + id: "test-item", + name: "Test Item", + description: "Test Description", + type: "package", + url: "https://test.com", + repoUrl: "https://github.com/test/repo", + author: "Test Author", + version: "1.0.0", + } + + const mockConfig: RocketConfig = { + parameters: [ + { + id: "testText", + resolver: { + operation: "prompt", + type: "text", + label: "Text Input", + initial: "default text", + }, + }, + { + id: "testConfirm", + resolver: { + operation: "prompt", + type: "confirm", + label: "Confirm Input", + initial: true, + }, + }, + ], + } + + it("renders sidebar with item name", () => { + renderWithProviders( + {}} onSubmit={() => {}} />, + ) + + expect(screen.getByText(`Install ${mockItem.name}`)).toBeInTheDocument() + }) + + it("renders text input parameter", () => { + renderWithProviders( + {}} onSubmit={() => {}} />, + ) + + const textInput = screen.getByTestId("text-testText") + expect(textInput).toBeInTheDocument() + expect(textInput).toHaveValue("default text") + }) + + it("renders checkbox parameter", () => { + renderWithProviders( + {}} onSubmit={() => {}} />, + ) + + const checkbox = screen.getByTestId("checkbox-testConfirm") + expect(checkbox).toBeInTheDocument() + expect(checkbox).toBeChecked() + }) + + it("updates text parameter value", () => { + const onSubmit = jest.fn() + renderWithProviders( + {}} onSubmit={onSubmit} />, + ) + + const textInput = screen.getByTestId("text-testText") + fireEvent.change(textInput, { target: { value: "new value" } }) + + const installButton = screen.getByText("Install") + fireEvent.click(installButton) + + expect(onSubmit).toHaveBeenCalledWith(mockItem, { + testText: "new value", + testConfirm: true, + }) + }) + + it("updates checkbox parameter value", () => { + const onSubmit = jest.fn() + renderWithProviders( + {}} onSubmit={onSubmit} />, + ) + + const checkbox = screen.getByTestId("checkbox-testConfirm") + fireEvent.click(checkbox) + + const installButton = screen.getByText("Install") + fireEvent.click(installButton) + + expect(onSubmit).toHaveBeenCalledWith(mockItem, { + testText: "default text", + testConfirm: false, + }) + }) + + it("calls onClose when clicking outside sidebar", () => { + const onClose = jest.fn() + renderWithProviders( + {}} />, + ) + + // Click the overlay (parent div) + const overlay = screen.getByText(`Install ${mockItem.name}`).parentElement?.parentElement + if (overlay) { + fireEvent.click(overlay) + } + + expect(onClose).toHaveBeenCalled() + }) + + it("calls onClose when clicking cancel button", () => { + const onClose = jest.fn() + renderWithProviders( + {}} />, + ) + + const cancelButton = screen.getByText("Cancel") + fireEvent.click(cancelButton) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx new file mode 100644 index 0000000000..22f073ab25 --- /dev/null +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx @@ -0,0 +1,188 @@ +import { render, screen, fireEvent } from "@testing-library/react" +import { MarketplaceListView } from "../MarketplaceListView" +import { MarketplaceItem } from "../../../../../src/services/marketplace/types" +import { ViewState } from "../MarketplaceViewStateManager" +import userEvent from "@testing-library/user-event" + +// Mock translation hook +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, // Return the key as-is for easy testing + }), +})) + +// Mock ResizeObserver +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +global.ResizeObserver = MockResizeObserver + +// Mock state manager with initial state +const mockTransition = jest.fn() +const mockState: ViewState = { + allItems: [], + displayItems: [], + isFetching: false, + activeTab: "browse", + refreshingUrls: [], + sources: [], + installedMetadata: { + project: {}, + global: {}, + }, + filters: { + type: "", + search: "", + tags: [], + }, + sortConfig: { + by: "name", + order: "asc", + }, +} + +// Mock useStateManager hook +jest.mock("../useStateManager", () => ({ + useStateManager: () => [mockState, { transition: mockTransition }], +})) + +// Mock all lucide-react icons +jest.mock("lucide-react", () => { + return new Proxy( + {}, + { + get: function (obj, prop) { + if (prop === "__esModule") { + return true + } + return () =>
{String(prop)}
+ }, + }, + ) +}) + +const defaultProps = { + stateManager: {} as any, + allTags: ["tag1", "tag2"], + filteredTags: ["tag1", "tag2"], + tagSearch: "", + setTagSearch: jest.fn(), + isTagPopoverOpen: false, + setIsTagPopoverOpen: jest.fn(), +} + +describe("MarketplaceListView", () => { + beforeEach(() => { + jest.clearAllMocks() + mockState.filters.tags = [] + mockState.isFetching = false + mockState.displayItems = [] + }) + + it("renders search input", () => { + render() + + const searchInput = screen.getByPlaceholderText("marketplace:filters.search.placeholder") + expect(searchInput).toBeInTheDocument() + }) + + it("renders type filter", () => { + render() + + expect(screen.getByText("marketplace:filters.type.label")).toBeInTheDocument() + expect(screen.getByText("marketplace:filters.type.all")).toBeInTheDocument() + }) + + it("renders sort options", () => { + render() + + expect(screen.getByText("marketplace:filters.sort.label")).toBeInTheDocument() + expect(screen.getByText("marketplace:filters.sort.name")).toBeInTheDocument() + }) + + it("renders tags section when tags are available", () => { + render() + + expect(screen.getByText("marketplace:filters.tags.label")).toBeInTheDocument() + expect(screen.getByText("(2)")).toBeInTheDocument() // Shows tag count + }) + + it("shows loading state when fetching", () => { + mockState.isFetching = true + + render() + + expect(screen.getByText("marketplace:items.refresh.refreshing")).toBeInTheDocument() + expect(screen.getByText("This may take a moment...")).toBeInTheDocument() + }) + + it("shows empty state when no items and not fetching", () => { + render() + + expect(screen.getByText("marketplace:items.empty.noItems")).toBeInTheDocument() + expect(screen.getByText("Try adjusting your filters or search terms")).toBeInTheDocument() + }) + + it("shows items count when items are available", () => { + const mockItems: MarketplaceItem[] = [ + { + id: "1", + repoUrl: "test1", + name: "Test 1", + type: "mode", + description: "Test description 1", + url: "https://test1.com", + version: "1.0.0", + author: "Test Author 1", + lastUpdated: "2024-01-01", + }, + { + id: "2", + repoUrl: "test2", + name: "Test 2", + type: "mode", + description: "Test description 2", + url: "https://test2.com", + version: "1.0.0", + author: "Test Author 2", + lastUpdated: "2024-01-02", + }, + ] + mockState.displayItems = mockItems + + render() + + expect(screen.getByText("marketplace:items.count")).toBeInTheDocument() + }) + + it("updates search filter when typing", () => { + render() + + const searchInput = screen.getByPlaceholderText("marketplace:filters.search.placeholder") + fireEvent.change(searchInput, { target: { value: "test" } }) + + expect(mockTransition).toHaveBeenCalledWith({ + type: "UPDATE_FILTERS", + payload: { filters: { search: "test" } }, + }) + }) + + it("shows clear tags button when tags are selected", async () => { + const user = userEvent.setup() + mockState.filters.tags = ["tag1"] + + render() + + const clearButton = screen.getByText("marketplace:filters.tags.clear") + expect(clearButton).toBeInTheDocument() + + await user.click(clearButton) + expect(mockTransition).toHaveBeenCalledWith({ + type: "UPDATE_FILTERS", + payload: { filters: { tags: [] } }, + }) + }) +}) diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx new file mode 100644 index 0000000000..fa30257ed1 --- /dev/null +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx @@ -0,0 +1,230 @@ +import { render, fireEvent, screen } from "@testing-library/react" +import { MarketplaceSourcesConfig } from "../MarketplaceSourcesConfigView" +import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager" + +// Mock the translation hook +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, // Return the key as-is for testing + }), +})) + +describe("MarketplaceSourcesConfig", () => { + let stateManager: MarketplaceViewStateManager + + beforeEach(() => { + stateManager = new MarketplaceViewStateManager() + // Reset state manager to have no sources + stateManager.transition({ + type: "UPDATE_SOURCES", + payload: { sources: [] }, + }) + jest.clearAllMocks() + }) + + it("shows source count", () => { + render() + const countElement = screen.getByText((content) => content.includes("/ 10")) + expect(countElement).toBeInTheDocument() + }) + + it("adds a new source with URL only", async () => { + render() + + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + const testUrl = "https://github.com/test/repo-1" + fireEvent.change(urlInput, { target: { value: testUrl } }) + + const addButton = screen.getByText("marketplace:sources.add.button") + fireEvent.click(addButton) + + const sources = stateManager.getState().sources + const newSource = sources.find((s) => s.url === testUrl) + expect(newSource).toEqual({ + url: testUrl, + enabled: true, + }) + }) + + it("adds a new source with URL and name", async () => { + render() + + const nameInput = screen.getByPlaceholderText("marketplace:sources.add.namePlaceholder") + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + const testUrl = "https://github.com/test/repo-2" + + fireEvent.change(nameInput, { target: { value: "Test Source" } }) + fireEvent.change(urlInput, { target: { value: testUrl } }) + + const addButton = screen.getByText("marketplace:sources.add.button") + fireEvent.click(addButton) + + const sources = stateManager.getState().sources + const newSource = sources.find((s) => s.url === testUrl) + expect(newSource).toEqual({ + url: testUrl, + name: "Test Source", + enabled: true, + }) + }) + + it("shows error when URL is empty", () => { + render() + + const addButton = screen.getByText("marketplace:sources.add.button") + fireEvent.click(addButton) + + const errorElement = screen.getByText("marketplace:sources.errors.invalidGitUrl") + expect(errorElement).toBeInTheDocument() + }) + + it("shows error when max sources reached", () => { + // Add max number of sources with unique URLs + const maxSources = Array(10) + .fill(null) + .map((_, i) => ({ + url: `https://github.com/test/repo-${i}`, + enabled: true, + })) + + stateManager.transition({ + type: "UPDATE_SOURCES", + payload: { sources: maxSources }, + }) + + render() + + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + fireEvent.change(urlInput, { target: { value: "https://github.com/test/new" } }) + + const addButton = screen.getByText("marketplace:sources.add.button") + fireEvent.click(addButton) + + const errorElement = screen.getByText("marketplace:sources.errors.maxSources") + expect(errorElement).toBeInTheDocument() + }) + + it("accepts multi-part corporate git URLs", async () => { + render() + + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + const gitUrl = "git@git.lab.company.com:team-core/project-name.git" + fireEvent.change(urlInput, { target: { value: gitUrl } }) + + const addButton = screen.getByText("marketplace:sources.add.button") + fireEvent.click(addButton) + + const sources = stateManager.getState().sources + const newSource = sources.find((s) => s.url === gitUrl) + expect(newSource).toEqual({ + url: gitUrl, + enabled: true, + }) + }) + + it("toggles source enabled state", () => { + const testUrl = "https://github.com/test/repo-3" + stateManager.transition({ + type: "UPDATE_SOURCES", + payload: { + sources: [ + { + url: testUrl, + enabled: true, + }, + ], + }, + }) + + render() + + const checkbox = screen.getByRole("checkbox", { name: "" }) + fireEvent.click(checkbox) + + const sources = stateManager.getState().sources + const updatedSource = sources.find((s) => s.url === testUrl) + expect(updatedSource?.enabled).toBe(false) + }) + + it("removes a source", () => { + const testUrl = "https://github.com/test/repo-4" + stateManager.transition({ + type: "UPDATE_SOURCES", + payload: { + sources: [ + { + url: testUrl, + enabled: true, + }, + ], + }, + }) + + render() + + const removeButtons = screen.getAllByTitle("marketplace:sources.current.remove") + fireEvent.click(removeButtons[0]) + + const sources = stateManager.getState().sources + expect(sources.find((s) => s.url === testUrl)).toBeUndefined() + }) + + it("refreshes a source", () => { + const testUrl = "https://github.com/test/repo-5" + stateManager.transition({ + type: "UPDATE_SOURCES", + payload: { + sources: [ + { + url: testUrl, + enabled: true, + }, + ], + }, + }) + + render() + + const refreshButtons = screen.getAllByTitle("marketplace:sources.current.refresh") + fireEvent.click(refreshButtons[0]) + + expect(stateManager.getState().refreshingUrls).toContain(testUrl) + }) + + it("limits source name to 20 characters", () => { + render() + + const nameInput = screen.getByPlaceholderText("marketplace:sources.add.namePlaceholder") + const longName = "This is a very long source name that exceeds limit" + fireEvent.change(nameInput, { target: { value: longName } }) + + // The component should truncate to 20 chars + expect(nameInput).toHaveValue(longName.slice(0, 20)) + }) + + it("shows character count for source name", () => { + render() + + const nameInput = screen.getByPlaceholderText("marketplace:sources.add.namePlaceholder") + fireEvent.change(nameInput, { target: { value: "Test Source" } }) + + expect(screen.getByText("11/20")).toBeInTheDocument() + }) + + it("clears inputs after adding source", () => { + render() + + const nameInput = screen.getByPlaceholderText("marketplace:sources.add.namePlaceholder") + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + const testUrl = "https://github.com/test/repo-6" + + fireEvent.change(nameInput, { target: { value: "Test Source" } }) + fireEvent.change(urlInput, { target: { value: testUrl } }) + + const addButton = screen.getByText("marketplace:sources.add.button") + fireEvent.click(addButton) + + expect(nameInput).toHaveValue("") + expect(urlInput).toHaveValue("") + }) +}) diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts b/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts new file mode 100644 index 0000000000..e6fbca68a5 --- /dev/null +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts @@ -0,0 +1,1236 @@ +import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager" +import { vscode } from "../../../utils/vscode" +import { MarketplaceItemType, MarketplaceItem, MarketplaceSource } from "../../../../../src/services/marketplace/types" +import { DEFAULT_MARKETPLACE_SOURCE } from "../../../../../src/services/marketplace/constants" + +const createTestItem = (overrides = {}): MarketplaceItem => ({ + id: "test", + name: "test", + type: "mode" as MarketplaceItemType, + description: "Test mode", + url: "https://github.com/test/repo", + repoUrl: "https://github.com/test/repo", + author: "Test Author", + version: "1.0.0", + sourceName: "Test Source", + sourceUrl: "https://github.com/test/repo", + ...overrides, +}) + +const createTestSources = (): MarketplaceSource[] => [ + { url: "https://github.com/test/repo1", enabled: true }, + { url: "https://github.com/test/repo2", enabled: true }, + { url: "https://github.com/test/repo3", enabled: true }, +] + +// Mock vscode.postMessage +jest.mock("../../../utils/vscode", () => ({ + vscode: { + postMessage: jest.fn(), + }, +})) + +describe("MarketplaceViewStateManager", () => { + let manager: MarketplaceViewStateManager + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + manager = new MarketplaceViewStateManager() + manager.initialize() // Send initial sources + }) + + afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + }) + + describe("Initial State", () => { + it("should initialize with default state", () => { + const state = manager.getState() + expect(state).toEqual({ + allItems: [], + displayItems: [], + isFetching: false, + activeTab: "browse", + refreshingUrls: [], + sources: [DEFAULT_MARKETPLACE_SOURCE], + filters: { + type: "", + search: "", + tags: [], + }, + sortConfig: { + by: "name", + order: "asc", + }, + }) + }) + + it("should send initial sources when initialized", () => { + manager.initialize() + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "marketplaceSources", + sources: [DEFAULT_MARKETPLACE_SOURCE], + }) + }) + + it("should initialize with default source", () => { + const manager = new MarketplaceViewStateManager() + + // Initial state should include default source + const state = manager.getState() + expect(state.sources).toEqual([ + { + url: "https://github.com/RooVetGit/Roo-Code-Marketplace", + name: "Roo Code", + enabled: true, + }, + ]) + + // Verify initial message was sent to update sources + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "marketplaceSources", + sources: [ + { + url: "https://github.com/RooVetGit/Roo-Code-Marketplace", + name: "Roo Code", + enabled: true, + }, + ], + }) + }) + }) + + describe("Fetch Transitions", () => { + it("should handle FETCH_ITEMS transition", async () => { + jest.clearAllMocks() // Clear mock to ignore initialize() call + await manager.transition({ type: "FETCH_ITEMS" }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "fetchMarketplaceItems", + bool: true, + }) + + const state = manager.getState() + expect(state.isFetching).toBe(true) + }) + + it("should not start a new fetch if one is in progress", async () => { + jest.clearAllMocks() // Clear mock to ignore initialize() call + // Start first fetch + await manager.transition({ type: "FETCH_ITEMS" }) + + // Try to start second fetch + await manager.transition({ type: "FETCH_ITEMS" }) + + // postMessage should only be called once + expect(vscode.postMessage).toHaveBeenCalledTimes(1) + }) + + it("should handle FETCH_COMPLETE transition", async () => { + const testItems = [createTestItem()] + + await manager.transition({ type: "FETCH_ITEMS" }) + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: testItems }, + }) + + const state = manager.getState() + expect(state.isFetching).toBe(false) + expect(state.allItems).toEqual(testItems) + }) + + it("should handle FETCH_ERROR transition", async () => { + await manager.transition({ type: "FETCH_ITEMS" }) + await manager.transition({ type: "FETCH_ERROR" }) + + const state = manager.getState() + expect(state.isFetching).toBe(false) + }) + }) + + describe("Race Conditions", () => { + it("should maintain items state when repeatedly switching tabs", async () => { + // Start with initial items + const initialItems = [createTestItem({ name: "Initial Item" })] + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: initialItems }, + }) + + // First switch to sources + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + // Switch back to browse + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + // Verify items are preserved after first switch + let state = manager.getState() + expect(state.displayItems).toEqual(initialItems) + expect(state.allItems).toEqual(initialItems) + + // Simulate receiving empty response during fetch + await manager.handleMessage({ + type: "state", + state: { marketplaceItems: [] }, + }) + + // Verify items are still preserved + state = manager.getState() + expect(state.displayItems).toEqual(initialItems) + expect(state.allItems).toEqual(initialItems) + + // Switch to sources again + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + // Switch back to browse again + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + // Verify items are still preserved after second switch + state = manager.getState() + expect(state.displayItems).toEqual(initialItems) + expect(state.allItems).toEqual(initialItems) + + // Simulate another empty response + await manager.handleMessage({ + type: "state", + state: { marketplaceItems: [] }, + }) + + // Final verification that items are still preserved + state = manager.getState() + expect(state.displayItems).toEqual(initialItems) + expect(state.allItems).toEqual(initialItems) + }) + + it("should preserve items when receiving empty response", async () => { + // Start with initial items + const initialItems = [createTestItem({ name: "Initial Item" })] + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: initialItems }, + }) + + // Verify initial state + let state = manager.getState() + expect(state.allItems).toEqual(initialItems) + expect(state.displayItems).toEqual(initialItems) + + // Simulate receiving an empty response + await manager.handleMessage({ + type: "state", + state: { marketplaceItems: [] }, + }) + + // Verify items are preserved + state = manager.getState() + expect(state.allItems).toEqual(initialItems) + expect(state.displayItems).toEqual(initialItems) + expect(state.isFetching).toBe(false) + }) + + it("should preserve items when switching tabs", async () => { + // Start with initial items + const initialItems = [createTestItem({ name: "Initial Item" })] + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: initialItems }, + }) + + // Switch to sources tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + // Switch back to browse + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + // Verify that items are preserved + const state = manager.getState() + expect(state.displayItems).toEqual(initialItems) + expect(state.allItems).toEqual(initialItems) + }) + + it("should handle rapid filtering during initial load", async () => { + // Start initial load + await manager.transition({ type: "FETCH_ITEMS" }) + + // Quickly apply filters + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters: { type: "mode" } }, + }) + + // Complete the initial load + await manager.handleMessage({ + type: "state", + state: { marketplaceItems: [createTestItem()] }, + }) + + // Fast-forward past debounce time + jest.advanceTimersByTime(300) + + const state = manager.getState() + expect(state.filters.type).toBe("mode") + // We don't preserve allItems during filtering anymore + expect(state.displayItems).toBeDefined() + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "filterMarketplaceItems", + filters: expect.objectContaining({ type: "mode" }), + }), + ) + }) + + it("should handle concurrent filter operations", async () => { + // Reset mock before test + ;(vscode.postMessage as jest.Mock).mockClear() + + // Apply first filter + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters: { search: "test" } }, + }) + + // Apply second filter + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters: { type: "mode" } }, + }) + + // Each filter update should be sent immediately + expect(vscode.postMessage).toHaveBeenCalledTimes(2) + expect(vscode.postMessage).toHaveBeenLastCalledWith({ + type: "filterMarketplaceItems", + filters: { + search: "test", + type: "mode", + tags: [], + }, + }) + }) + + it("should handle rapid source deletions", async () => { + // Reset mock before test + ;(vscode.postMessage as jest.Mock).mockClear() + + // Create test sources + const testSources = createTestSources() + + // Set initial sources and wait for state update + await manager.transition({ + type: "UPDATE_SOURCES", + payload: { sources: testSources }, + }) + + // Delete all sources at once + await manager.transition({ + type: "UPDATE_SOURCES", + payload: { sources: [] }, + }) + + // Wait for state to settle + jest.runAllTimers() + + // Get all calls to postMessage + const calls = (vscode.postMessage as jest.Mock).mock.calls + const sourcesMessages = calls.filter((call) => call[0].type === "marketplaceSources") + const lastSourcesMessage = sourcesMessages[sourcesMessages.length - 1] + + // Verify state has default source + const state = manager.getState() + expect(state.sources).toEqual([DEFAULT_MARKETPLACE_SOURCE]) + + // Verify the last sources message was sent with default source + expect(lastSourcesMessage[0]).toEqual({ + type: "marketplaceSources", + sources: [DEFAULT_MARKETPLACE_SOURCE], + }) + }) + + it("should handle rapid source operations during fetch when in browse tab", async () => { + // Switch to browse tab first + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + // Start a fetch + await manager.transition({ type: "FETCH_ITEMS" }) + + // Rapidly update sources while fetch is in progress + const sources = [{ url: "https://github.com/test/repo1", enabled: true }] + + await manager.transition({ + type: "UPDATE_SOURCES", + payload: { sources }, + }) + + // Complete the fetch + await manager.handleMessage({ + type: "state", + state: { marketplaceItems: [createTestItem()] }, + }) + + const state = manager.getState() + expect(state.sources).toEqual(sources) + expect(state.allItems).toHaveLength(1) + expect(state.isFetching).toBe(false) + }) + }) + + describe("Error Handling", () => { + it("should handle fetch timeout", async () => { + await manager.transition({ type: "FETCH_ITEMS" }) + + // Fast-forward past the timeout + jest.advanceTimersByTime(30000) + + const state = manager.getState() + expect(state.isFetching).toBe(false) + }) + + it("should handle invalid message types gracefully", () => { + manager.handleMessage({ type: "invalidType" }) + const state = manager.getState() + expect(state.isFetching).toBe(false) + expect(state.allItems).toEqual([]) + }) + + it("should handle invalid state message format", () => { + manager.handleMessage({ type: "state", state: {} }) + const state = manager.getState() + expect(state.allItems).toEqual([]) + }) + + it("should handle invalid transition payloads", async () => { + // @ts-ignore - Testing invalid payload + await manager.transition({ type: "UPDATE_FILTERS", payload: { invalid: true } }) + const state = manager.getState() + expect(state.filters).toEqual({ + type: "", + search: "", + tags: [], + }) + }) + }) + + describe("Filter Behavior", () => { + it("should send filter updates immediately", async () => { + // Reset mock before test + ;(vscode.postMessage as jest.Mock).mockClear() + + // Apply first filter + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters: { search: "test1" } }, + }) + + // Apply second filter + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters: { search: "test2" } }, + }) + + // Apply third filter + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters: { search: "test3" } }, + }) + + // Should send all updates immediately + expect(vscode.postMessage).toHaveBeenCalledTimes(3) + expect(vscode.postMessage).toHaveBeenLastCalledWith({ + type: "filterMarketplaceItems", + filters: { + type: "", + search: "test3", + tags: [], + }, + }) + }) + + it("should send filter message immediately when filters are cleared", async () => { + // First set some filters + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { + filters: { + type: "mode", + search: "test", + }, + }, + }) + + // Clear mock to ignore the first filter message + ;(vscode.postMessage as jest.Mock).mockClear() + + // Clear filters + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { + filters: { + type: "", + search: "", + tags: [], + }, + }, + }) + + // Should send filter message with empty filters immediately + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "filterMarketplaceItems", + filters: { + type: "", + search: "", + tags: [], + }, + }) + }) + + it("should maintain filter criteria when search is cleared", async () => { + // Reset mock before test + ;(vscode.postMessage as jest.Mock).mockClear() + + // First set a type filter + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { + filters: { type: "mode" }, + }, + }) + + // Then add a search term + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { + filters: { search: "test" }, + }, + }) + + // Clear the search term + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { + filters: { search: "" }, + }, + }) + + // Should maintain type filter when search is cleared + expect(vscode.postMessage).toHaveBeenLastCalledWith({ + type: "filterMarketplaceItems", + filters: { + type: "mode", + search: "", + tags: [], + }, + }) + + const state = manager.getState() + expect(state.filters).toEqual({ + type: "mode", + search: "", + tags: [], + }) + }) + }) + + describe("Message Handling", () => { + it("should handle repository refresh completion", () => { + const url = "https://example.com/repo" + + // First add URL to refreshing list + manager.transition({ + type: "REFRESH_SOURCE", + payload: { url }, + }) + + // Then handle completion message + manager.handleMessage({ + type: "repositoryRefreshComplete", + url, + }) + + const state = manager.getState() + expect(state.refreshingUrls).not.toContain(url) + }) + + it("should handle marketplace button click with error", () => { + manager.handleMessage({ + type: "marketplaceButtonClicked", + text: "error", + }) + + const state = manager.getState() + expect(state.isFetching).toBe(false) + }) + + it("should handle marketplace button click for refresh", () => { + manager.handleMessage({ + type: "marketplaceButtonClicked", + }) + + const state = manager.getState() + expect(state.isFetching).toBe(true) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "fetchMarketplaceItems", + bool: true, + }) + }) + }) + + describe("Tab Management", () => { + it("should handle SET_ACTIVE_TAB transition", async () => { + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + const state = manager.getState() + expect(state.activeTab).toBe("sources") + }) + + it("should trigger initial fetch when switching to browse with no items", async () => { + jest.clearAllMocks() // Clear mock to ignore initialize() call + + // Start in sources tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + // Switch to browse tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "fetchMarketplaceItems", + bool: true, + }) + }) + + it("should not trigger fetch when switching to browse with existing items", async () => { + jest.clearAllMocks() // Clear mock to ignore initialize() call + + // Add some items first + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: [createTestItem()] }, + }) + + // Switch to sources tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + // Switch back to browse tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + expect(vscode.postMessage).not.toHaveBeenCalledWith({ + type: "fetchMarketplaceItems", + bool: true, + }) + }) + + it("should automatically fetch when sources are modified and viewing browse tab", async () => { + jest.clearAllMocks() // Clear mock to ignore initialize() call + + // Add some items first + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: [createTestItem()] }, + }) + + // Switch to browse tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + // Modify sources + await manager.transition({ + type: "UPDATE_SOURCES", + payload: { sources: [{ url: "https://github.com/test/repo1", enabled: true }] }, + }) + + // Should trigger fetch due to source modification + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "fetchMarketplaceItems", + bool: true, + }) + }) + + it("should not trigger fetch when switching to sources tab", async () => { + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + expect(vscode.postMessage).not.toHaveBeenCalledWith({ + type: "fetchMarketplaceItems", + bool: true, + }) + }) + }) + + describe("Fetch Timeout Handling", () => { + it("should handle fetch timeout", async () => { + await manager.transition({ type: "FETCH_ITEMS" }) + + // Fast-forward past the timeout + jest.advanceTimersByTime(30000) + + const state = manager.getState() + expect(state.isFetching).toBe(false) + }) + + it("should clear timeout on successful fetch", async () => { + await manager.transition({ type: "FETCH_ITEMS" }) + + // Complete fetch before timeout + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: [createTestItem()] }, + }) + + // Fast-forward past the timeout + jest.advanceTimersByTime(30000) + + // State should still reflect successful fetch + const state = manager.getState() + expect(state.isFetching).toBe(false) + expect(state.allItems).toHaveLength(1) + }) + + it("should not switch tabs when timeout occurs while in sources tab", async () => { + // First switch to sources tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "sources" }, + }) + + // Start a fetch + await manager.transition({ type: "FETCH_ITEMS" }) + + // Set up a state change handler to track tab changes + let tabSwitched = false + const unsubscribe = manager.onStateChange((state) => { + if (state.activeTab === "browse") { + tabSwitched = true + } + }) + + // Fast-forward past the timeout + jest.advanceTimersByTime(30000) + + // Clean up the handler + unsubscribe() + + // Verify the tab didn't switch to browse + expect(tabSwitched).toBe(false) + const state = manager.getState() + expect(state.activeTab).toBe("sources") + }) + + it("should make minimal state updates when timeout occurs in browse tab", async () => { + // First ensure we're in browse tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + // Add some items + const testItems = [createTestItem(), createTestItem({ name: "Item 2" })] + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: testItems }, + }) + + // Start a new fetch + await manager.transition({ type: "FETCH_ITEMS" }) + + // Track state changes + let stateChangeCount = 0 + const unsubscribe = manager.onStateChange(() => { + stateChangeCount++ + }) + + // Reset the counter since we've already had state changes + stateChangeCount = 0 + + // Fast-forward past the timeout + jest.advanceTimersByTime(30000) + + // Clean up the handler + unsubscribe() + + // Verify we got a state update + expect(stateChangeCount).toBe(1) + + // Verify the items were preserved + const state = manager.getState() + expect(state.allItems).toHaveLength(2) + expect(state.isFetching).toBe(false) + expect(state.activeTab).toBe("browse") + }) + + it("should prevent concurrent fetches during timeout period", async () => { + jest.clearAllMocks() // Clear mock to ignore initialize() call + + // Start first fetch + await manager.transition({ type: "FETCH_ITEMS" }) + + // Attempt second fetch before timeout + jest.advanceTimersByTime(15000) + await manager.transition({ type: "FETCH_ITEMS" }) + + // postMessage should only be called once + expect(vscode.postMessage).toHaveBeenCalledTimes(1) + }) + }) + + // Filter behavior tests are already covered in the previous describe block + + describe("Source Management", () => { + beforeEach(() => { + // Mock setTimeout to execute immediately + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it("should trigger fetch for remaining source after source deletion when in browse tab", async () => { + // Start with two sources + const sources = [ + { url: "https://github.com/test/repo1", enabled: true }, + { url: "https://github.com/test/repo2", enabled: true }, + ] + + // Switch to browse tab + await manager.transition({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + + await manager.transition({ + type: "UPDATE_SOURCES", + payload: { sources }, + }) + + // Clear mock to ignore initial fetch + ;(vscode.postMessage as jest.Mock).mockClear() + + // Delete one source + await manager.transition({ + type: "UPDATE_SOURCES", + payload: { sources: [sources[0]] }, + }) + + // Verify that a fetch was triggered for the remaining source + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "fetchMarketplaceItems", + bool: true, + }) + + // Verify state has the remaining source + const state = manager.getState() + expect(state.sources).toEqual([sources[0]]) + }) + + it("should re-add default source when all sources are removed", async () => { + // Add some test sources + const sources = [ + { url: "https://github.com/test/repo1", enabled: true }, + { url: "https://github.com/test/repo2", enabled: true }, + ] + + await manager.transition({ + type: "UPDATE_SOURCES", + payload: { sources }, + }) + + // Clear mock to ignore previous messages + ;(vscode.postMessage as jest.Mock).mockClear() + + // Remove all sources + await manager.transition({ + type: "UPDATE_SOURCES", + payload: { sources: [] }, + }) + + // Run any pending timers before checking messages + jest.runAllTimers() + + // Get all calls to postMessage + const calls = (vscode.postMessage as jest.Mock).mock.calls + const sourcesMessage = calls.find((call) => call[0].type === "marketplaceSources") + + // Verify that the sources message was sent with default source + expect(sourcesMessage[0]).toEqual({ + type: "marketplaceSources", + sources: [ + { + url: "https://github.com/RooVetGit/Roo-Code-Marketplace", + name: "Roo Code", + enabled: true, + }, + ], + }) + }) + + it("should handle UPDATE_SOURCES transition", async () => { + const sources = [ + { url: "https://github.com/test/repo", enabled: true }, + { url: "https://github.com/test/repo2", enabled: false }, + ] + + await manager.transition({ + type: "UPDATE_SOURCES", + payload: { sources }, + }) + + const state = manager.getState() + expect(state.sources).toEqual(sources) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "marketplaceSources", + sources, + }) + }) + + it("should handle REFRESH_SOURCE transition", async () => { + const url = "https://github.com/test/repo" + + await manager.transition({ + type: "REFRESH_SOURCE", + payload: { url }, + }) + + const state = manager.getState() + expect(state.refreshingUrls).toContain(url) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "refreshMarketplaceSource", + url, + }) + }) + + it("should handle REFRESH_SOURCE_COMPLETE transition", async () => { + const url = "https://github.com/test/repo" + + // First add URL to refreshing list + await manager.transition({ + type: "REFRESH_SOURCE", + payload: { url }, + }) + + // Then complete the refresh + await manager.transition({ + type: "REFRESH_SOURCE_COMPLETE", + payload: { url }, + }) + + const state = manager.getState() + expect(state.refreshingUrls).not.toContain(url) + }) + }) + + describe("Filter Transitions", () => { + it("should preserve original items when receiving filtered results", async () => { + // Set up initial items + const initialItems = [ + createTestItem({ name: "Item 1" }), + createTestItem({ name: "Item 2" }), + createTestItem({ name: "Item 3" }), + ] + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: initialItems }, + }) + + // Apply a filter + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters: { search: "Item 1" } }, + }) + + // Fast-forward past debounce time + jest.advanceTimersByTime(300) + + // Simulate receiving filtered results + manager.handleMessage({ + type: "state", + state: { + marketplaceItems: [initialItems[0]], // Only Item 1 + }, + }) + + // We no longer preserve original items since we rely on backend filtering + const state = manager.getState() + expect(state.allItems).toBeDefined() + }) + + it("should handle UPDATE_FILTERS transition", async () => { + const filters = { + type: "mode", + search: "test", + tags: ["tag1"], + } + + await manager.transition({ + type: "UPDATE_FILTERS", + payload: { filters }, + }) + + const state = manager.getState() + expect(state.filters).toEqual(filters) + + // Fast-forward past debounce time + jest.advanceTimersByTime(300) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "filterMarketplaceItems", + filters: { + type: "mode", + search: "test", + tags: ["tag1"], + }, + }) + }) + }) + + describe("Sort Transitions", () => { + it("should sort items by name in ascending order", async () => { + const items = [ + createTestItem({ name: "B Component" }), + createTestItem({ name: "A Component" }), + createTestItem({ name: "C Component" }), + ] + + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items }, + }) + + await manager.transition({ + type: "UPDATE_SORT", + payload: { sortConfig: { by: "name", order: "asc" } }, + }) + + const state = manager.getState() + expect(state.allItems[0].name).toBe("A Component") + expect(state.allItems[1].name).toBe("B Component") + expect(state.allItems[2].name).toBe("C Component") + }) + + it("should sort items by lastUpdated in descending order", async () => { + const items = [ + createTestItem({ lastUpdated: "2025-04-13T09:00:00-07:00" }), + createTestItem({ lastUpdated: "2025-04-14T09:00:00-07:00" }), + createTestItem({ lastUpdated: "2025-04-12T09:00:00-07:00" }), + ] + + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items }, + }) + + await manager.transition({ + type: "UPDATE_SORT", + payload: { sortConfig: { by: "lastUpdated", order: "desc" } }, + }) + + const state = manager.getState() + expect(state.allItems[0].lastUpdated).toBe("2025-04-14T09:00:00-07:00") + expect(state.allItems[1].lastUpdated).toBe("2025-04-13T09:00:00-07:00") + expect(state.allItems[2].lastUpdated).toBe("2025-04-12T09:00:00-07:00") + }) + + it("should maintain sort order when items are updated", async () => { + const items = [ + createTestItem({ name: "B Component" }), + createTestItem({ name: "A Component" }), + createTestItem({ name: "C Component" }), + ] + + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items }, + }) + + await manager.transition({ + type: "UPDATE_SORT", + payload: { sortConfig: { by: "name", order: "asc" } }, + }) + + // Add a new item + const newItems = [...items, createTestItem({ name: "D Component" })] + + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items: newItems }, + }) + + const state = manager.getState() + expect(state.allItems[0].name).toBe("A Component") + expect(state.allItems[1].name).toBe("B Component") + expect(state.allItems[2].name).toBe("C Component") + expect(state.allItems[3].name).toBe("D Component") + }) + + it("should handle missing values gracefully", async () => { + const items = [ + createTestItem({ name: "B Component", lastUpdated: undefined }), + createTestItem({ name: "A Component", lastUpdated: "2025-04-14T09:00:00-07:00" }), + ] + + await manager.transition({ + type: "FETCH_COMPLETE", + payload: { items }, + }) + + await manager.transition({ + type: "UPDATE_SORT", + payload: { sortConfig: { by: "lastUpdated", order: "desc" } }, + }) + + const state = manager.getState() + expect(state.allItems[0].lastUpdated).toBe("2025-04-14T09:00:00-07:00") + expect(state.allItems[1].lastUpdated).toBeUndefined() + }) + }) + + describe("Message Handling", () => { + it("should restore sources from marketplaceSources on webview launch", () => { + const savedSources = [ + { + url: "https://github.com/RooVetGit/Roo-Code-Marketplace", + name: "Roo Code", + enabled: true, + }, + { + url: "https://github.com/test/custom-repo", + name: "Custom Repo", + enabled: true, + }, + ] + + // Simulate VS Code restart by sending initial state with saved sources + manager.handleMessage({ + type: "state", + state: { marketplaceSources: savedSources }, + }) + + const state = manager.getState() + expect(state.sources).toEqual(savedSources) + }) + + it("should use default source when state message has no sources", () => { + manager.handleMessage({ + type: "state", + state: { marketplaceItems: [] }, + }) + + const state = manager.getState() + expect(state.sources).toEqual([DEFAULT_MARKETPLACE_SOURCE]) + }) + + it("should update sources when receiving state message", () => { + const customSources = [ + { + url: "https://github.com/test/repo1", + name: "Test Repo 1", + enabled: true, + }, + { + url: "https://github.com/test/repo2", + name: "Test Repo 2", + enabled: true, + }, + ] + + manager.handleMessage({ + type: "state", + state: { sources: customSources }, + }) + + const state = manager.getState() + expect(state.sources).toEqual(customSources) + }) + + it("should handle state message with marketplace items", () => { + const testItems = [createTestItem()] + + // We need to use any here since we're testing the raw message handling + manager.handleMessage({ + type: "state", + state: { marketplaceItems: testItems }, + } as any) + + const state = manager.getState() + expect(state.allItems).toEqual(testItems) + }) + + it("should handle repositoryRefreshComplete message", () => { + const url = "https://example.com/repo" + + // First add URL to refreshing list + manager.transition({ + type: "REFRESH_SOURCE", + payload: { url }, + }) + + // Then handle completion message + manager.handleMessage({ + type: "repositoryRefreshComplete", + url, + }) + + const state = manager.getState() + expect(state.refreshingUrls).not.toContain(url) + }) + + it("should handle marketplaceButtonClicked message with error", () => { + manager.handleMessage({ + type: "marketplaceButtonClicked", + text: "error", + }) + + const state = manager.getState() + expect(state.isFetching).toBe(false) + }) + + it("should handle marketplaceButtonClicked message for refresh", () => { + manager.handleMessage({ + type: "marketplaceButtonClicked", + }) + + const state = manager.getState() + expect(state.isFetching).toBe(true) + }) + }) +}) diff --git a/webview-ui/src/components/marketplace/components/ExpandableSection.tsx b/webview-ui/src/components/marketplace/components/ExpandableSection.tsx new file mode 100644 index 0000000000..d9e85c736f --- /dev/null +++ b/webview-ui/src/components/marketplace/components/ExpandableSection.tsx @@ -0,0 +1,52 @@ +import React from "react" +import { cn } from "@/lib/utils" +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@src/components/ui/accordion" + +interface ExpandableSectionProps { + title: string + children: React.ReactNode + className?: string + defaultExpanded?: boolean + badge?: string +} + +export const ExpandableSection: React.FC = ({ + title, + children, + className, + defaultExpanded = false, + badge, +}) => { + // Create a unique value for the accordion item + const accordionValue = React.useMemo(() => `section-${title.replace(/\s+/g, "-").toLowerCase()}`, [title]) + + return ( + + + +
+ + + {title} + + {badge && ( + + {badge} + + )} +
+
+ + {children} + +
+
+ ) +} diff --git a/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx new file mode 100644 index 0000000000..64970581c1 --- /dev/null +++ b/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useMemo } from "react" +import { Button } from "@/components/ui/button" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { MoreVertical, ExternalLink, Download, Trash } from "lucide-react" +import { + InstallMarketplaceItemOptions, + MarketplaceItem, + RemoveInstalledMarketplaceItemOptions, +} from "../../../../../src/services/marketplace/types" +import { vscode } from "@/utils/vscode" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { isValidUrl } from "@roo/utils/url" +import { ItemInstalledMetadata } from "@roo/services/marketplace/InstalledMetadataManager" + +interface MarketplaceItemActionsMenuProps { + item: MarketplaceItem + installed: { + project: ItemInstalledMetadata | undefined + global: ItemInstalledMetadata | undefined + } +} + +export const MarketplaceItemActionsMenu: React.FC = ({ item, installed }) => { + const { t } = useAppTranslation() + + const itemSourceUrl = useMemo(() => { + if (item.sourceUrl && isValidUrl(item.sourceUrl)) { + return item.sourceUrl + } + + let url = item.repoUrl + if (item.defaultBranch) { + url = `${url}/tree/${item.defaultBranch}` + if (item.path) { + const normalizedPath = item.path.replace(/\\/g, "/").replace(/^\/+/, "") + url = `${url}/${normalizedPath}` + } + } + return url + }, [item.sourceUrl, item.repoUrl, item.defaultBranch, item.path]) + + const handleOpenSourceUrl = useCallback(() => { + vscode.postMessage({ + type: "openExternal", + url: itemSourceUrl, + }) + }, [itemSourceUrl]) + + const handleInstall = (options?: InstallMarketplaceItemOptions) => { + vscode.postMessage({ + type: "installMarketplaceItem", + mpItem: item, + mpInstallOptions: options, + }) + } + + const handleRemove = (options?: RemoveInstalledMarketplaceItemOptions) => { + vscode.postMessage({ + type: "removeInstalledMarketplaceItem", + mpItem: item, + mpInstallOptions: options, + }) + } + + return ( + + + + + + {/* View Source / External Link Item */} + + + {t("marketplace:items.card.viewSource")} + + + {/* Remove (Project) */} + {installed.project ? ( + handleRemove({ target: "project" })}> + + {t("marketplace:items.card.removeProject")} + + ) : ( + handleInstall({ target: "project" })}> + + {t("marketplace:items.card.installProject")} + + )} + + {/* Remove (Global) */} + {installed.global ? ( + handleRemove({ target: "global" })}> + + {t("marketplace:items.card.removeGlobal")} + + ) : ( + handleInstall({ target: "global" })}> + + {t("marketplace:items.card.installGlobal")} + + )} + + + ) +} diff --git a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx new file mode 100644 index 0000000000..fdcad95a6c --- /dev/null +++ b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx @@ -0,0 +1,216 @@ +import React, { useMemo } from "react" +import { MarketplaceItem } from "@roo/services/marketplace/types" // Updated import path +import { vscode } from "@/utils/vscode" +import { groupItemsByType, GroupedItems } from "../utils/grouping" +import { ExpandableSection } from "./ExpandableSection" +import { TypeGroup } from "./TypeGroup" +import { ViewState } from "../MarketplaceViewStateManager" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { MarketplaceItemActionsMenu } from "./MarketplaceItemActionsMenu" +import { isValidUrl } from "@roo/utils/url" +import { ItemInstalledMetadata } from "@roo/services/marketplace/InstalledMetadataManager" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { Rocket, Server, Package, Sparkles, Download } from "lucide-react" + +interface MarketplaceItemCardProps { + item: MarketplaceItem + installed: { + project: ItemInstalledMetadata | undefined + global: ItemInstalledMetadata | undefined + } + filters: ViewState["filters"] + setFilters: (filters: Partial) => void + activeTab: ViewState["activeTab"] + setActiveTab: (tab: ViewState["activeTab"]) => void +} + +const icons = { + mode: , + mcp: , + package: , + prompt: , +} + +export const MarketplaceItemCard: React.FC = ({ + item, + installed, + filters, + setFilters, + activeTab, + setActiveTab, +}) => { + const { t } = useAppTranslation() + + const typeLabel = useMemo(() => { + const labels: Partial> = { + mode: t("marketplace:filters.type.mode"), + mcp: t("marketplace:filters.type.mcp server"), + prompt: t("marketplace:filters.type.prompt"), + package: t("marketplace:filters.type.package"), + } + return labels[item.type] ?? "N/A" + }, [item.type, t]) + + const groupedItems = useMemo(() => { + if (!item.items?.length) return null + return groupItemsByType(item.items) + }, [item.items]) as GroupedItems | null + + const expandableSectionBadge = useMemo(() => { + const matchCount = item.items?.filter((subItem) => subItem.matchInfo?.matched).length ?? 0 + return matchCount > 0 ? t("marketplace:items.components", { count: matchCount }) : undefined + }, [item.items, t]) + + return ( +
+
+ {installed.project && ( + + + + + + This package is installed in your current project workspace + + )} + {installed.global && ( + + + + + + This package is installed globally on your system + + )} +
+
+
+

{item.name}

+ +
+ + {icons[item.type]} {typeLabel} + +
+ +

{item.description}

+ + {item.tags && item.tags.length > 0 && ( +
+ {item.tags.map((tag) => ( + + ))} +
+ )} + +
+
+ {item.version && ( + + + {item.version} + + )} + {item.lastUpdated && ( + + + {new Date(item.lastUpdated).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + )} +
+ + +
+ + {item.type === "package" && ( + subItem.matchInfo?.matched) ?? false}> +
+ {groupedItems && + Object.entries(groupedItems).map(([type, group]) => ( + + ))} +
+
+ )} +
+ ) +} + +interface AuthorInfoProps { + item: MarketplaceItem +} + +const AuthorInfo: React.FC = ({ item }) => { + const { t } = useAppTranslation() + + const handleOpenAuthorUrl = () => { + if (item.authorUrl && isValidUrl(item.authorUrl)) { + vscode.postMessage({ type: "openExternal", url: item.authorUrl }) + } + } + + if (item.author) { + return ( +

+ {item.authorUrl && isValidUrl(item.authorUrl) ? ( + + ) : ( + t("marketplace:items.card.by", { author: item.author }) + )} +

+ ) + } + return null +} diff --git a/webview-ui/src/components/marketplace/components/TypeGroup.tsx b/webview-ui/src/components/marketplace/components/TypeGroup.tsx new file mode 100644 index 0000000000..efa2c07afe --- /dev/null +++ b/webview-ui/src/components/marketplace/components/TypeGroup.tsx @@ -0,0 +1,143 @@ +import React, { useMemo } from "react" +import { cn } from "@/lib/utils" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { Rocket, Server, Package, Sparkles } from "lucide-react" + +interface TypeGroupProps { + type: "mode" | "mcp" | "prompt" | "package" | (string & {}) + items: Array<{ + name: string + description?: string + metadata?: any + path?: string + matchInfo?: { + matched: boolean + matchReason?: Record + } + }> + className?: string +} + +const typeIcons = { + mode: , + mcp: , + prompt: , + package: , +} + +export const TypeGroup: React.FC = ({ type, items, className }) => { + const { t } = useAppTranslation() + const typeLabel = useMemo(() => { + switch (type) { + case "mode": + return t("marketplace:type-group.modes") + case "mcp": + return t("marketplace:type-group.mcps") + case "prompt": + return t("marketplace:type-group.prompts") + case "package": + return t("marketplace:type-group.packages") + default: + return t("marketplace:type-group.generic-type", { + type: type.charAt(0).toUpperCase() + type.slice(1), + }) + } + }, [type, t]) + + // Get the appropriate icon for the type + const typeIcon = typeIcons[type as keyof typeof typeIcons] || + + // Determine if we should use horizontal layout (modes only for now) or card layout (for mcps) + const isHorizontalLayout = type === "mode" + + // Memoize the list items + const listItems = useMemo(() => { + if (!items?.length) return null + + if (isHorizontalLayout) { + // Horizontal layout for modes + return ( +
+ {items.map((item, index) => { + const cardClassName = cn( + "flex items-center gap-2 py-1 px-2 rounded-md bg-vscode-input-background/50", + "hover:border-vscode-focusBorder transition-colors", + { + "border-vscode-textLink": item.matchInfo?.matched, + "border-vscode-panel-border": !item.matchInfo?.matched, + }, + ) + + return ( +
+ + {item.name} + + {item.matchInfo?.matched && ( + + {t("marketplace:type-group.match")} + + )} +
+ ) + })} +
+ ) + } else { + return ( +
+ {items.map((item, index) => ( +
+
+
+ {item.name} +
+ {item.matchInfo?.matched && ( + + {t("marketplace:type-group.match")} + + )} +
+ {item.description && ( +

{item.description}

+ )} +
+ ))} +
+ ) + } + }, [items, t, isHorizontalLayout]) + + if (!items?.length) { + return null + } + + return ( +
+
+
+ {typeIcon} +
+

{typeLabel}

+
+ {listItems} +
+ ) +} diff --git a/webview-ui/src/components/marketplace/components/__tests__/ExpandableSection.test.tsx b/webview-ui/src/components/marketplace/components/__tests__/ExpandableSection.test.tsx new file mode 100644 index 0000000000..d4f38a49c0 --- /dev/null +++ b/webview-ui/src/components/marketplace/components/__tests__/ExpandableSection.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { ExpandableSection } from "../ExpandableSection" + +// Mock ChevronDownIcon used in Accordion component +jest.mock("lucide-react", () => ({ + ChevronDownIcon: () =>
, +})) + +// Mock ResizeObserver +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +global.ResizeObserver = MockResizeObserver + +describe("ExpandableSection", () => { + it("renders with basic props", () => { + render( + +
Test Content
+
, + ) + + expect(screen.getByText("Test Section")).toBeInTheDocument() + // Content is hidden in accordion + const content = screen.getByRole("region", { hidden: true }) + expect(content).toHaveAttribute("hidden") + }) + + it("applies custom className", () => { + render( + +
Test Content
+
, + ) + + const accordion = screen.getByTestId("chevron-icon").closest(".border-t-0") + expect(accordion).toHaveClass("custom-class") + }) + + it("renders badge when provided", () => { + render( + +
Test Content
+
, + ) + + expect(screen.getByText("123")).toBeInTheDocument() + expect(screen.getByText("123")).toHaveClass( + "text-xs", + "bg-vscode-badge-background", + "text-vscode-badge-foreground", + ) + }) + + it("expands and collapses on click", async () => { + const user = userEvent.setup() + render( + +
Test Content
+
, + ) + + const trigger = screen.getByRole("button") + const content = screen.getByRole("region", { hidden: true }) + + // Initially hidden + expect(content).toHaveAttribute("hidden") + + // Expand + await user.click(trigger) + expect(content).not.toHaveAttribute("hidden") + + // Collapse + await user.click(trigger) + expect(content).toHaveAttribute("hidden") + }) + + it("starts expanded when defaultExpanded is true", () => { + render( + +
Test Content
+
, + ) + + const content = screen.getByRole("region") + expect(content).not.toHaveAttribute("hidden") + }) + + it("has correct accessibility attributes", () => { + render( + +
Test Content
+
, + ) + + const trigger = screen.getByRole("button") + const content = screen.getByRole("region", { hidden: true }) + + expect(trigger).toHaveAttribute("id", "details-button") + expect(trigger).toHaveAttribute("aria-controls", "details-content") + expect(content).toHaveAttribute("id", "details-content") + expect(content).toHaveAttribute("aria-labelledby", "details-button") + }) + + it("renders list icon", () => { + render( + +
Test Content
+
, + ) + + const icon = screen.getByRole("button").querySelector(".codicon-list-unordered") + expect(icon).toHaveClass("codicon", "codicon-list-unordered") + }) +}) diff --git a/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx new file mode 100644 index 0000000000..c5dc519718 --- /dev/null +++ b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx @@ -0,0 +1,218 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { MarketplaceItemCard } from "../MarketplaceItemCard" +import { vscode } from "@/utils/vscode" +import { MarketplaceItem } from "@roo/services/marketplace/types" +import { TooltipProvider } from "@/components/ui/tooltip" +import { AccordionTrigger } from "@/components/ui/accordion" + +// Mock vscode API +jest.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: jest.fn(), + }, +})) + +// Mock MarketplaceItemActionsMenu component +jest.mock("../MarketplaceItemActionsMenu", () => ({ + MarketplaceItemActionsMenu: () =>
, +})) + +// Mock ChevronDownIcon for Accordion +jest.mock("@/components/ui/accordion", () => { + const actual = jest.requireActual("@/components/ui/accordion") + return { + ...actual, + AccordionTrigger: ({ children, ...props }: React.ComponentProps) => ( + + ), + } +}) + +// Mock translation hook +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, params?: any) => { + if (key === "marketplace:items.card.by") { + return `by ${params.author}` + } + const translations: Record = { + "marketplace:filters.type.mode": "Mode", + "marketplace:filters.type.mcp server": "MCP Server", + "marketplace:filters.type.prompt": "Prompt", + "marketplace:filters.type.package": "Package", + "marketplace:filters.tags.clear": "Remove filter", + "marketplace:filters.tags.clickToFilter": "Add filter", + "marketplace:items.components": "Components", + } + return translations[key] || key + }, + }), +})) + +// Mock icons +jest.mock("lucide-react", () => ({ + Rocket: () =>
, + Server: () =>
, + Package: () =>
, + Sparkles: () =>
, + Download: () =>
, +})) + +const renderWithProviders = (ui: React.ReactElement) => { + return render({ui}) +} + +describe("MarketplaceItemCard", () => { + const defaultItem: MarketplaceItem = { + id: "test-item", + name: "Test Item", + description: "Test Description", + type: "mode", + version: "1.0.0", + author: "Test Author", + authorUrl: "https://example.com", + lastUpdated: "2024-01-01", + tags: ["test", "example"], + repoUrl: "https://github.com/test/repo", + url: "https://example.com/item", + } + + const defaultProps = { + item: defaultItem, + installed: { + project: undefined, + global: undefined, + }, + filters: { + type: "", + search: "", + tags: [], + }, + setFilters: jest.fn(), + activeTab: "browse" as const, + setActiveTab: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders basic item information", () => { + renderWithProviders() + + expect(screen.getByText("Test Item")).toBeInTheDocument() + expect(screen.getByText("Test Description")).toBeInTheDocument() + expect(screen.getByText("by Test Author")).toBeInTheDocument() + expect(screen.getByText("1.0.0")).toBeInTheDocument() + expect(screen.getByText("Jan 1, 2024")).toBeInTheDocument() + }) + + it("renders project installation badge", () => { + renderWithProviders( + , + ) + + expect(screen.getByText("Project")).toBeInTheDocument() + expect(screen.getByLabelText("Installed in project")).toBeInTheDocument() + }) + + it("renders global installation badge", () => { + renderWithProviders( + , + ) + + expect(screen.getByText("Global")).toBeInTheDocument() + expect(screen.getByLabelText("Installed globally")).toBeInTheDocument() + }) + + it("renders type with appropriate icon", () => { + renderWithProviders() + + expect(screen.getByText("Mode")).toBeInTheDocument() + expect(screen.getByTestId("rocket-icon")).toBeInTheDocument() + }) + + it("renders tags and handles tag clicks", async () => { + const user = userEvent.setup() + const setFilters = jest.fn() + const setActiveTab = jest.fn() + + renderWithProviders( + , + ) + + const tagButton = screen.getByText("test") + await user.click(tagButton) + + expect(setFilters).toHaveBeenCalledWith({ tags: ["test"] }) + expect(setActiveTab).not.toHaveBeenCalled() // Already on browse tab + }) + + it("handles author link click", async () => { + const user = userEvent.setup() + renderWithProviders() + + const authorLink = screen.getByText("by Test Author") + await user.click(authorLink) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: "https://example.com", + }) + }) + + it("renders package components when available", () => { + const packageItem: MarketplaceItem = { + ...defaultItem, + type: "package", + items: [ + { + type: "mode", + path: "test/path", + matchInfo: { matched: true }, + metadata: { + name: "Component 1", + description: "Test Component", + type: "mode", + version: "1.0.0", + }, + }, + ], + } + + renderWithProviders() + + // Find the section title by its parent button + const sectionTitle = screen.getByRole("button", { name: /Components/ }) + expect(sectionTitle).toBeInTheDocument() + expect(screen.getByText("Component 1")).toBeInTheDocument() + }) + + it("does not render invalid author URLs", () => { + const itemWithInvalidUrl: MarketplaceItem = { + ...defaultItem, + authorUrl: "invalid-url", + } + + renderWithProviders() + + const authorText = screen.getByText("by Test Author") + expect(authorText.tagName).not.toBe("BUTTON") + }) +}) diff --git a/webview-ui/src/components/marketplace/components/__tests__/TypeGroup.test.tsx b/webview-ui/src/components/marketplace/components/__tests__/TypeGroup.test.tsx new file mode 100644 index 0000000000..19466a81b3 --- /dev/null +++ b/webview-ui/src/components/marketplace/components/__tests__/TypeGroup.test.tsx @@ -0,0 +1,122 @@ +import { render, screen } from "@testing-library/react" +import { TypeGroup } from "../TypeGroup" + +// Mock translation hook +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, params?: any) => { + if (key === "marketplace:type-group.generic-type") { + return params.type + } + const translations: Record = { + "marketplace:type-group.modes": "Modes", + "marketplace:type-group.mcps": "MCPs", + "marketplace:type-group.prompts": "Prompts", + "marketplace:type-group.packages": "Packages", + "marketplace:type-group.match": "Match", + } + return translations[key] || key + }, + }), +})) + +// Mock icons +jest.mock("lucide-react", () => ({ + Rocket: () =>
, + Server: () =>
, + Package: () =>
, + Sparkles: () =>
, +})) + +describe("TypeGroup", () => { + const defaultItems = [ + { + name: "Test Item", + description: "Test Description", + path: "test/path", + }, + ] + + it("renders nothing when items array is empty", () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it("renders mode type with horizontal layout", () => { + render() + + expect(screen.getByText("Modes")).toBeInTheDocument() + expect(screen.getByTestId("rocket-icon")).toBeInTheDocument() + + // Find the grid container + const gridContainer = screen.getByText("Test Item").closest(".grid") + expect(gridContainer).toHaveClass("grid-cols-[repeat(auto-fit,minmax(140px,1fr))]") + }) + + it("renders mcp type with vertical layout", () => { + render() + + expect(screen.getByText("MCPs")).toBeInTheDocument() + expect(screen.getByTestId("server-icon")).toBeInTheDocument() + + // Find the grid container + const gridContainer = screen.getByText("Test Item").closest(".grid") + expect(gridContainer).toHaveClass("grid-cols-1") + }) + + it("renders prompt type correctly", () => { + render() + + expect(screen.getByText("Prompts")).toBeInTheDocument() + expect(screen.getByTestId("sparkles-icon")).toBeInTheDocument() + }) + + it("renders package type correctly", () => { + render() + + expect(screen.getByText("Packages")).toBeInTheDocument() + expect(screen.getByTestId("package-icon")).toBeInTheDocument() + }) + + it("renders custom type with generic label", () => { + render() + + expect(screen.getByText("Custom")).toBeInTheDocument() + // Falls back to package icon + expect(screen.getByTestId("package-icon")).toBeInTheDocument() + }) + + it("renders matched items with special styling", () => { + const matchedItems = [ + { + name: "Matched Item", + description: "Test Description", + path: "test/path", + matchInfo: { + matched: true, + matchReason: { name: true }, + }, + }, + ] + + render() + + const matchedText = screen.getByText("Matched Item") + expect(matchedText).toHaveClass("text-vscode-textLink") + expect(screen.getByText("Match")).toBeInTheDocument() + }) + + it("renders description when provided", () => { + render() + + expect(screen.getByText("Test Description")).toBeInTheDocument() + expect(screen.getByText("Test Description")).toHaveClass("text-vscode-descriptionForeground") + }) + + it("applies custom className", () => { + render() + + const container = screen.getByText("Modes").closest(".custom-class") + expect(container).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/marketplace/useStateManager.ts b/webview-ui/src/components/marketplace/useStateManager.ts new file mode 100644 index 0000000000..deaf22c821 --- /dev/null +++ b/webview-ui/src/components/marketplace/useStateManager.ts @@ -0,0 +1,44 @@ +import { useState, useEffect } from "react" +import { MarketplaceViewStateManager, ViewState } from "./MarketplaceViewStateManager" + +export function useStateManager(existingManager?: MarketplaceViewStateManager) { + const [manager] = useState(() => existingManager || new MarketplaceViewStateManager()) + const [state, setState] = useState(() => manager.getState()) + + useEffect(() => { + const handleStateChange = (newState: ViewState) => { + setState((prevState) => { + // Compare specific state properties that matter for rendering + const hasChanged = + prevState.isFetching !== newState.isFetching || + prevState.activeTab !== newState.activeTab || + JSON.stringify(prevState.allItems) !== JSON.stringify(newState.allItems) || + JSON.stringify(prevState.displayItems) !== JSON.stringify(newState.displayItems) || + JSON.stringify(prevState.filters) !== JSON.stringify(newState.filters) || + JSON.stringify(prevState.sources) !== JSON.stringify(newState.sources) || + JSON.stringify(prevState.refreshingUrls) !== JSON.stringify(newState.refreshingUrls) || + JSON.stringify(prevState.installedMetadata) !== JSON.stringify(newState.installedMetadata) + + return hasChanged ? newState : prevState + }) + } + + const handleMessage = (event: MessageEvent) => { + manager.handleMessage(event.data) + } + + window.addEventListener("message", handleMessage) + const unsubscribe = manager.onStateChange(handleStateChange) + + return () => { + window.removeEventListener("message", handleMessage) + unsubscribe() + // Don't cleanup the manager if it was provided externally + if (!existingManager) { + manager.cleanup() + } + } + }, [manager, existingManager]) + + return [state, manager] as const +} diff --git a/webview-ui/src/components/marketplace/utils/__tests__/grouping.test.ts b/webview-ui/src/components/marketplace/utils/__tests__/grouping.test.ts new file mode 100644 index 0000000000..47e88abb84 --- /dev/null +++ b/webview-ui/src/components/marketplace/utils/__tests__/grouping.test.ts @@ -0,0 +1,120 @@ +import { groupItemsByType, formatItemText, getTotalItemCount, getUniqueTypes } from "../grouping" +import { MarketplaceItem } from "../../../../../../src/services/marketplace/types" + +describe("grouping utilities", () => { + const mockItems = [ + { + type: "mcp", + path: "servers/test-server", + metadata: { + name: "Test Server", + description: "A test server", + version: "1.0.0", + }, + }, + { + type: "mode", + path: "modes/test-mode", + metadata: { + name: "Test Mode", + description: "A test mode", + version: "2.0.0", + }, + }, + { + type: "mcp", + path: "servers/another-server", + metadata: { + name: "Another Server", + description: "Another test server", + version: "1.1.0", + }, + }, + ] as MarketplaceItem["items"] + + describe("groupItemsByType", () => { + it("should group items by type correctly", () => { + const result = groupItemsByType(mockItems) + + expect(Object.keys(result)).toHaveLength(2) + expect(result["mcp"].items).toHaveLength(2) + expect(result["mode"].items).toHaveLength(1) + + expect(result["mcp"].items[0].name).toBe("Test Server") + expect(result["mode"].items[0].name).toBe("Test Mode") + }) + + it("should handle empty items array", () => { + expect(groupItemsByType([])).toEqual({}) + expect(groupItemsByType(undefined)).toEqual({}) + }) + + it("should handle items with missing metadata", () => { + const itemsWithMissingData = [ + { + type: "mcp", + path: "test/path", + }, + ] as MarketplaceItem["items"] + + const result = groupItemsByType(itemsWithMissingData) + expect(result["mcp"].items[0].name).toBe("Unnamed item") + }) + + it("should preserve item order within groups", () => { + const result = groupItemsByType(mockItems) + const servers = result["mcp"].items + + expect(servers[0].name).toBe("Test Server") + expect(servers[1].name).toBe("Another Server") + }) + + it("should skip items without type", () => { + const itemsWithoutType = [ + { + path: "test/path", + metadata: { name: "Test" }, + }, + ] as MarketplaceItem["items"] + + const result = groupItemsByType(itemsWithoutType) + expect(Object.keys(result)).toHaveLength(0) + }) + }) + + describe("formatItemText", () => { + it("should format item with name and description", () => { + const item = { name: "Test", description: "Description" } + expect(formatItemText(item)).toBe("Test - Description") + }) + + it("should handle items without description", () => { + const item = { name: "Test" } + expect(formatItemText(item)).toBe("Test") + }) + }) + + describe("getTotalItemCount", () => { + it("should count total items across all groups", () => { + const groups = groupItemsByType(mockItems) + expect(getTotalItemCount(groups)).toBe(3) + }) + + it("should handle empty groups", () => { + expect(getTotalItemCount({})).toBe(0) + }) + }) + + describe("getUniqueTypes", () => { + it("should return sorted array of unique types", () => { + const groups = groupItemsByType(mockItems) + const types = getUniqueTypes(groups) + + expect(types).toEqual(["mcp", "mode"]) + }) + + it("should handle empty groups", () => { + expect(getUniqueTypes({})).toEqual([]) + }) + }) +}) diff --git a/webview-ui/src/components/marketplace/utils/grouping.ts b/webview-ui/src/components/marketplace/utils/grouping.ts new file mode 100644 index 0000000000..6d33405c69 --- /dev/null +++ b/webview-ui/src/components/marketplace/utils/grouping.ts @@ -0,0 +1,90 @@ +import { MarketplaceItem } from "../../../../../src/services/marketplace/types" + +export interface GroupedItems { + [type: string]: { + type: string + items: Array<{ + name: string + description?: string + metadata?: any + path?: string + matchInfo?: { + matched: boolean + matchReason?: Record + } + }> + } +} + +/** + * Groups package items by their type + * @param items Array of items to group + * @returns Object with items grouped by type + */ +export function groupItemsByType(items: MarketplaceItem["items"] = []): GroupedItems { + if (!items?.length) { + return {} + } + + const groups: GroupedItems = {} + + for (const item of items) { + if (!item.type) continue + + if (!groups[item.type]) { + groups[item.type] = { + type: item.type, + items: [], + } + } + + groups[item.type].items.push({ + name: item.metadata?.name || "Unnamed item", + description: item.metadata?.description, + metadata: item.metadata, + path: item.path, + matchInfo: item.matchInfo, + }) + } + + return groups +} + +/** + * Gets a formatted string representation of an item + * @param item The item to format + * @returns Formatted string with name and description + */ +export function formatItemText(item: { name: string; description?: string }): string { + if (!item.description) { + return item.name + } + + const maxLength = 100 + const result = + item.name + + " - " + + (item.description.length > maxLength ? item.description.substring(0, maxLength) + "..." : item.description) + + return result +} + +/** + * Gets the total number of items across all groups + * @param groups Grouped items object + * @returns Total number of items + */ +export function getTotalItemCount(groups: GroupedItems): number { + return Object.values(groups).reduce((total, group) => total + group.items.length, 0) +} + +/** + * Gets an array of unique types from the grouped items + * @param groups Grouped items object + * @returns Array of type strings + */ +export function getUniqueTypes(groups: GroupedItems): string[] { + const types = Object.keys(groups) + types.sort() + return types +} diff --git a/webview-ui/src/components/ui/accordion.tsx b/webview-ui/src/components/ui/accordion.tsx new file mode 100644 index 0000000000..c9d1dccb40 --- /dev/null +++ b/webview-ui/src/components/ui/accordion.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Accordion({ ...props }: React.ComponentProps) { + return +} + +function AccordionItem({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ className, children, ...props }: React.ComponentProps) { + return ( + + svg]:rotate-180", + className, + )} + {...props}> + {children} + + + + ) +} + +function AccordionContent({ className, children, ...props }: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 53629f942e..44fe5c5d3b 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -11,6 +11,8 @@ import { CustomSupportPrompts } from "@roo/shared/support-prompt" import { experimentDefault, ExperimentId } from "@roo/shared/experiments" import { TelemetrySetting } from "@roo/shared/TelemetrySetting" import { RouterModels } from "@roo/shared/api" +import { DEFAULT_MARKETPLACE_SOURCE } from "@roo/services/marketplace/constants" +import { MarketplaceSource } from "@roo/services/marketplace/types" import { vscode } from "@src/utils/vscode" import { convertTextMateToHljs } from "@src/utils/textMateToHljs" @@ -104,6 +106,7 @@ export interface ExtensionStateContextType extends ExtensionState { autoCondenseContextPercent: number setAutoCondenseContextPercent: (value: number) => void routerModels?: RouterModels + setMarketplaceSources: (value: MarketplaceSource[]) => void } export const ExtensionStateContext = createContext(undefined) @@ -178,6 +181,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior). renderContext: "sidebar", maxReadFileLine: -1, // Default max read file line limit + marketplaceSources: [DEFAULT_MARKETPLACE_SOURCE], pinnedApiConfigs: {}, // Empty object for pinned API configs terminalZshOhMy: false, // Default Oh My Zsh integration setting terminalZshP10k: false, // Default Powerlevel10k integration setting @@ -383,6 +387,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setCondensingApiConfigId: (value) => setState((prevState) => ({ ...prevState, condensingApiConfigId: value })), setCustomCondensingPrompt: (value) => setState((prevState) => ({ ...prevState, customCondensingPrompt: value })), + setMarketplaceSources: (value) => setState((prevState) => ({ ...prevState, marketplaceSources: value })), } return {children} diff --git a/webview-ui/src/i18n/locales/ca/marketplace.json b/webview-ui/src/i18n/locales/ca/marketplace.json new file mode 100644 index 0000000000..15b516e83b --- /dev/null +++ b/webview-ui/src/i18n/locales/ca/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "Marketplace", + "tabs": { + "browse": "Explorar", + "sources": "Fonts" + }, + "filters": { + "search": { + "placeholder": "Cercar elements del marketplace..." + }, + "type": { + "label": "Filtrar per tipus:", + "all": "Tots els tipus", + "mode": "Mode", + "mcp": "Servidor MCP", + "prompt": "Prompt", + "package": "Paquet" + }, + "sort": { + "label": "Ordenar per:", + "name": "Nom", + "author": "Autor", + "lastUpdated": "Última actualització" + }, + "tags": { + "label": "Filtrar per etiquetes:", + "available": "{{count}} disponibles", + "clear": "Netejar etiquetes ({{count}})", + "placeholder": "Escriu per cercar i seleccionar etiquetes...", + "noResults": "No s'han trobat etiquetes coincidents", + "selected": "Mostrant elements amb qualsevol de les etiquetes seleccionades ({{count}} seleccionades)", + "clickToFilter": "Fes clic a les etiquetes per filtrar elements" + } + }, + "type-group": { + "match": "Coincidència", + "modes": "Modes", + "mcps": "Servidors MCP", + "prompts": "Prompts", + "packages": "Paquets", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "No s'han trobat elements al marketplace", + "withFilters": "Prova d'ajustar els filtres", + "noSources": "Prova d'afegir una font a la pestanya Fonts" + }, + "count": "S'han trobat {{count}} elements", + "components": "{{count}} components", + "refresh": { + "button": "Actualitzar", + "refreshing": "Actualitzant..." + }, + "card": { + "by": "per {{author}}", + "from": "de {{source}}", + "viewSource": "Veure", + "viewOnSource": "Veure a {{source}}" + } + }, + "sources": { + "title": "Configurar fonts del Marketplace", + "description": "Afegeix repositoris Git que continguin elements del marketplace. Aquests repositoris es descarregaran quan s'explori el marketplace.", + "add": { + "title": "Afegir nova font", + "urlPlaceholder": "URL del repositori Git (p. ex., https://github.com/username/repo)", + "urlFormats": "Formats admesos: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), o protocol Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nom a mostrar (màx. 20 caràcters)", + "button": "Afegir font" + }, + "current": { + "title": "Fonts actuals", + "count": "{{current}}/{{max}} màx.", + "empty": "No hi ha fonts configurades. Afegeix una font per començar.", + "refresh": "Actualitzar aquesta font", + "remove": "Eliminar font" + }, + "errors": { + "emptyUrl": "L'URL no pot estar buida", + "invalidUrl": "Format d'URL no vàlid", + "nonVisibleChars": "L'URL conté caràcters no visibles diferents d'espais", + "invalidGitUrl": "L'URL ha de ser una URL vàlida de repositori Git (p. ex., https://github.com/username/repo)", + "duplicateUrl": "Aquesta URL ja és a la llista (coincidència insensible a majúscules i espais)", + "nameTooLong": "El nom ha de tenir 20 caràcters o menys", + "nonVisibleCharsName": "El nom conté caràcters no visibles diferents d'espais", + "duplicateName": "Aquest nom ja està en ús (coincidència insensible a majúscules i espais)", + "maxSources": "Màxim de {{max}} fonts permeses" + } + } +} diff --git a/webview-ui/src/i18n/locales/de/marketplace.json b/webview-ui/src/i18n/locales/de/marketplace.json new file mode 100644 index 0000000000..4ff6e5ec51 --- /dev/null +++ b/webview-ui/src/i18n/locales/de/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "Marketplace", + "tabs": { + "browse": "Durchsuchen", + "sources": "Quellen" + }, + "filters": { + "search": { + "placeholder": "Marketplace-Einträge durchsuchen..." + }, + "type": { + "label": "Nach Typ filtern:", + "all": "Alle Typen", + "mode": "Modus", + "mcp": "MCP-Server", + "prompt": "Prompt", + "package": "Paket" + }, + "sort": { + "label": "Sortieren nach:", + "name": "Name", + "author": "Autor", + "lastUpdated": "Zuletzt aktualisiert" + }, + "tags": { + "label": "Nach Tags filtern:", + "available": "{{count}} verfügbar", + "clear": "Tags löschen ({{count}})", + "placeholder": "Tippen Sie, um Tags zu suchen und auszuwählen...", + "noResults": "Keine passenden Tags gefunden", + "selected": "Zeige Einträge mit beliebigen ausgewählten Tags ({{count}} ausgewählt)", + "clickToFilter": "Klicken Sie auf Tags zum Filtern" + } + }, + "type-group": { + "match": "Treffer", + "modes": "Modi", + "mcps": "MCP-Server", + "prompts": "Prompts", + "packages": "Pakete", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "Keine Marketplace-Einträge gefunden", + "withFilters": "Versuchen Sie, die Filter anzupassen", + "noSources": "Versuchen Sie, eine Quelle im Quellen-Tab hinzuzufügen" + }, + "count": "{{count}} Einträge gefunden", + "components": "{{count}} Komponenten", + "refresh": { + "button": "Aktualisieren", + "refreshing": "Aktualisiere..." + }, + "card": { + "by": "von {{author}}", + "from": "von {{source}}", + "viewSource": "Ansehen", + "viewOnSource": "Auf {{source}} ansehen" + } + }, + "sources": { + "title": "Marketplace-Quellen konfigurieren", + "description": "Fügen Sie Git-Repositories hinzu, die Marketplace-Einträge enthalten. Diese Repositories werden beim Durchsuchen des Marketplaces abgerufen.", + "add": { + "title": "Neue Quelle hinzufügen", + "urlPlaceholder": "Git-Repository-URL (z.B. https://github.com/username/repo)", + "urlFormats": "Unterstützte Formate: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) oder Git-Protokoll (git://github.com/username/repo.git)", + "namePlaceholder": "Anzeigename (max. 20 Zeichen)", + "button": "Quelle hinzufügen" + }, + "current": { + "title": "Aktuelle Quellen", + "count": "{{current}}/{{max}} max.", + "empty": "Keine Quellen konfiguriert. Fügen Sie eine Quelle hinzu, um zu beginnen.", + "refresh": "Diese Quelle aktualisieren", + "remove": "Quelle entfernen" + }, + "errors": { + "emptyUrl": "URL darf nicht leer sein", + "invalidUrl": "Ungültiges URL-Format", + "nonVisibleChars": "URL enthält unsichtbare Zeichen außer Leerzeichen", + "invalidGitUrl": "URL muss eine gültige Git-Repository-URL sein (z.B. https://github.com/username/repo)", + "duplicateUrl": "Diese URL ist bereits in der Liste (Groß-/Kleinschreibung und Leerzeichen werden ignoriert)", + "nameTooLong": "Name darf maximal 20 Zeichen lang sein", + "nonVisibleCharsName": "Name enthält unsichtbare Zeichen außer Leerzeichen", + "duplicateName": "Dieser Name wird bereits verwendet (Groß-/Kleinschreibung und Leerzeichen werden ignoriert)", + "maxSources": "Maximal {{max}} Quellen erlaubt" + } + } +} diff --git a/webview-ui/src/i18n/locales/en/marketplace.json b/webview-ui/src/i18n/locales/en/marketplace.json new file mode 100644 index 0000000000..75496e5c36 --- /dev/null +++ b/webview-ui/src/i18n/locales/en/marketplace.json @@ -0,0 +1,94 @@ +{ + "title": "Marketplace", + "tabs": { + "browse": "Browse", + "sources": "Sources" + }, + "filters": { + "search": { + "placeholder": "Search marketplace items..." + }, + "type": { + "label": "Filter by type:", + "all": "All types", + "mode": "Mode", + "mcp server": "MCP Server", + "prompt": "Prompt", + "package": "Package" + }, + "sort": { + "label": "Sort by:", + "name": "Name", + "author": "Author", + "lastUpdated": "Last Updated" + }, + "tags": { + "label": "Filter by tags:", + "available": "{{count}} available", + "clear": "Clear tags ({{count}})", + "placeholder": "Type to search and select tags...", + "noResults": "No matching tags found", + "selected": "Showing items with any of the selected tags ({{count}} selected)", + "clickToFilter": "Click tags to filter items" + } + }, + "type-group": { + "match": "Match", + "modes": "Modes", + "mcps": "MCP Servers", + "prompts": "Prompts", + "packages": "Packages", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "No marketplace items found", + "withFilters": "Try adjusting your filters", + "noSources": "Try adding a source in the Sources tab" + }, + "count": "{{count}} items found", + "components": "{{count}} components", + "refresh": { + "button": "Refresh", + "refreshing": "Refreshing..." + }, + "card": { + "by": "by {{author}}", + "from": "from {{source}}", + "installProject": "Install (Project)", + "installGlobal": "Install (Global)", + "removeProject": "Remove (Project)", + "removeGlobal": "Remove (Global)", + "viewSource": "View", + "viewOnSource": "View on {{source}}" + } + }, + "sources": { + "title": "Configure Marketplace Sources", + "description": "Add Git repositories that contain marketplace items. These repositories will be fetched when browsing the marketplace.", + "add": { + "title": "Add New Source", + "urlPlaceholder": "Git repository URL (e.g., https://github.com/username/repo)", + "urlFormats": "Supported formats: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), or Git protocol (git://github.com/username/repo.git)", + "namePlaceholder": "Display name (max 20 chars)", + "button": "Add Source" + }, + "current": { + "title": "Current Sources", + "empty": "No sources configured. Add a source to get started.", + "refresh": "Refresh this source", + "remove": "Remove source" + }, + "errors": { + "emptyUrl": "URL cannot be empty", + "invalidUrl": "Invalid URL format", + "nonVisibleChars": "URL contains non-visible characters other than spaces", + "invalidGitUrl": "URL must be a valid Git repository URL (e.g., https://github.com/username/repo)", + "duplicateUrl": "This URL is already in the list (case and whitespace insensitive match)", + "nameTooLong": "Name must be 20 characters or less", + "nonVisibleCharsName": "Name contains non-visible characters other than spaces", + "duplicateName": "This name is already in use (case and whitespace insensitive match)", + "maxSources": "Maximum of {{max}} sources allowed" + } + } +} diff --git a/webview-ui/src/i18n/locales/es/marketplace.json b/webview-ui/src/i18n/locales/es/marketplace.json new file mode 100644 index 0000000000..4c3d777dde --- /dev/null +++ b/webview-ui/src/i18n/locales/es/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "Marketplace", + "tabs": { + "browse": "Explorar", + "sources": "Fuentes" + }, + "filters": { + "search": { + "placeholder": "Buscar elementos del marketplace..." + }, + "type": { + "label": "Filtrar por tipo:", + "all": "Todos los tipos", + "mode": "Modo", + "mcp": "Servidor MCP", + "prompt": "Prompt", + "package": "Paquete" + }, + "sort": { + "label": "Ordenar por:", + "name": "Nombre", + "author": "Autor", + "lastUpdated": "Última actualización" + }, + "tags": { + "label": "Filtrar por etiquetas:", + "available": "{{count}} disponibles", + "clear": "Limpiar etiquetas ({{count}})", + "placeholder": "Escriba para buscar y seleccionar etiquetas...", + "noResults": "No se encontraron etiquetas coincidentes", + "selected": "Mostrando elementos con cualquiera de las etiquetas seleccionadas ({{count}} seleccionadas)", + "clickToFilter": "Haga clic en las etiquetas para filtrar elementos" + } + }, + "type-group": { + "match": "Coincidencia", + "modes": "Modos", + "mcps": "Servidores MCP", + "prompts": "Prompts", + "packages": "Paquetes", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "No se encontraron elementos en el marketplace", + "withFilters": "Intente ajustar los filtros", + "noSources": "Intente agregar una fuente en la pestaña Fuentes" + }, + "count": "{{count}} elementos encontrados", + "components": "{{count}} componentes", + "refresh": { + "button": "Actualizar", + "refreshing": "Actualizando..." + }, + "card": { + "by": "por {{author}}", + "from": "de {{source}}", + "viewSource": "Ver", + "viewOnSource": "Ver en {{source}}" + } + }, + "sources": { + "title": "Configurar fuentes del Marketplace", + "description": "Agregue repositorios Git que contengan elementos del marketplace. Estos repositorios se descargarán al explorar el marketplace.", + "add": { + "title": "Agregar nueva fuente", + "urlPlaceholder": "URL del repositorio Git (ej., https://github.com/username/repo)", + "urlFormats": "Formatos admitidos: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) o protocolo Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nombre para mostrar (máx. 20 caracteres)", + "button": "Agregar fuente" + }, + "current": { + "title": "Fuentes actuales", + "count": "{{current}}/{{max}} máx.", + "empty": "No hay fuentes configuradas. Agregue una fuente para comenzar.", + "refresh": "Actualizar esta fuente", + "remove": "Eliminar fuente" + }, + "errors": { + "emptyUrl": "La URL no puede estar vacía", + "invalidUrl": "Formato de URL no válido", + "nonVisibleChars": "La URL contiene caracteres no visibles distintos de espacios", + "invalidGitUrl": "La URL debe ser una URL válida de repositorio Git (ej., https://github.com/username/repo)", + "duplicateUrl": "Esta URL ya está en la lista (coincidencia insensible a mayúsculas y espacios)", + "nameTooLong": "El nombre debe tener 20 caracteres o menos", + "nonVisibleCharsName": "El nombre contiene caracteres no visibles distintos de espacios", + "duplicateName": "Este nombre ya está en uso (coincidencia insensible a mayúsculas y espacios)", + "maxSources": "Máximo de {{max}} fuentes permitidas" + } + } +} diff --git a/webview-ui/src/i18n/locales/fr/marketplace.json b/webview-ui/src/i18n/locales/fr/marketplace.json new file mode 100644 index 0000000000..c6d53ba471 --- /dev/null +++ b/webview-ui/src/i18n/locales/fr/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "Marketplace", + "tabs": { + "browse": "Parcourir", + "sources": "Sources" + }, + "filters": { + "search": { + "placeholder": "Rechercher des éléments du marketplace..." + }, + "type": { + "label": "Filtrer par type :", + "all": "Tous les types", + "mode": "Mode", + "mcp": "Serveur MCP", + "prompt": "Prompt", + "package": "Paquet" + }, + "sort": { + "label": "Trier par :", + "name": "Nom", + "author": "Auteur", + "lastUpdated": "Dernière mise à jour" + }, + "tags": { + "label": "Filtrer par tags :", + "available": "{{count}} disponibles", + "clear": "Effacer les tags ({{count}})", + "placeholder": "Tapez pour rechercher et sélectionner des tags...", + "noResults": "Aucun tag correspondant trouvé", + "selected": "Affichage des éléments avec l'un des tags sélectionnés ({{count}} sélectionnés)", + "clickToFilter": "Cliquez sur les tags pour filtrer les éléments" + } + }, + "type-group": { + "match": "Correspondance", + "modes": "Modes", + "mcps": "Serveurs MCP", + "prompts": "Prompts", + "packages": "Paquets", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "Aucun élément trouvé dans le marketplace", + "withFilters": "Essayez d'ajuster les filtres", + "noSources": "Essayez d'ajouter une source dans l'onglet Sources" + }, + "count": "{{count}} éléments trouvés", + "components": "{{count}} composants", + "refresh": { + "button": "Actualiser", + "refreshing": "Actualisation..." + }, + "card": { + "by": "par {{author}}", + "from": "de {{source}}", + "viewSource": "Voir", + "viewOnSource": "Voir sur {{source}}" + } + }, + "sources": { + "title": "Configurer les sources du Marketplace", + "description": "Ajoutez des dépôts Git contenant des éléments du marketplace. Ces dépôts seront récupérés lors de la navigation dans le marketplace.", + "add": { + "title": "Ajouter une nouvelle source", + "urlPlaceholder": "URL du dépôt Git (ex., https://github.com/username/repo)", + "urlFormats": "Formats pris en charge : HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) ou protocole Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nom d'affichage (max. 20 caractères)", + "button": "Ajouter la source" + }, + "current": { + "title": "Sources actuelles", + "count": "{{current}}/{{max}} max.", + "empty": "Aucune source configurée. Ajoutez une source pour commencer.", + "refresh": "Actualiser cette source", + "remove": "Supprimer la source" + }, + "errors": { + "emptyUrl": "L'URL ne peut pas être vide", + "invalidUrl": "Format d'URL invalide", + "nonVisibleChars": "L'URL contient des caractères non visibles autres que des espaces", + "invalidGitUrl": "L'URL doit être une URL de dépôt Git valide (ex., https://github.com/username/repo)", + "duplicateUrl": "Cette URL est déjà dans la liste (correspondance insensible à la casse et aux espaces)", + "nameTooLong": "Le nom doit faire 20 caractères ou moins", + "nonVisibleCharsName": "Le nom contient des caractères non visibles autres que des espaces", + "duplicateName": "Ce nom est déjà utilisé (correspondance insensible à la casse et aux espaces)", + "maxSources": "Maximum de {{max}} sources autorisées" + } + } +} diff --git a/webview-ui/src/i18n/locales/hi/marketplace.json b/webview-ui/src/i18n/locales/hi/marketplace.json new file mode 100644 index 0000000000..fe579fdbf6 --- /dev/null +++ b/webview-ui/src/i18n/locales/hi/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "मार्केटप्लेस", + "tabs": { + "browse": "ब्राउज़ करें", + "sources": "स्रोत" + }, + "filters": { + "search": { + "placeholder": "मार्केटप्लेस आइटम खोजें..." + }, + "type": { + "label": "प्रकार से फ़िल्टर करें:", + "all": "सभी प्रकार", + "mode": "मोड", + "mcp": "एमसीपी सर्वर", + "prompt": "प्रॉम्प्ट", + "package": "पैकेज" + }, + "sort": { + "label": "इसके अनुसार क्रमबद्ध करें:", + "name": "नाम", + "author": "लेखक", + "lastUpdated": "अंतिम अपडेट" + }, + "tags": { + "label": "टैग से फ़िल्टर करें:", + "available": "{{count}} उपलब्ध", + "clear": "टैग साफ़ करें ({{count}})", + "placeholder": "टैग खोजने और चुनने के लिए टाइप करें...", + "noResults": "कोई मिलान टैग नहीं मिला", + "selected": "चयनित टैग वाले आइटम दिखा रहे हैं ({{count}} चयनित)", + "clickToFilter": "आइटम फ़िल्टर करने के लिए टैग पर क्लिक करें" + } + }, + "type-group": { + "match": "मिलान", + "modes": "मोड्स", + "mcps": "एमसीपी सर्वर", + "prompts": "प्रॉम्प्ट्स", + "packages": "पैकेज", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "कोई मार्केटप्लेस आइटम नहीं मिला", + "withFilters": "फ़िल्टर समायोजित करने का प्रयास करें", + "noSources": "स्रोत टैब में एक स्रोत जोड़ने का प्रयास करें" + }, + "count": "{{count}} आइटम मिले", + "components": "{{count}} कंपोनेंट", + "refresh": { + "button": "रीफ्रेश करें", + "refreshing": "रीफ्रेश हो रहा है..." + }, + "card": { + "by": "{{author}} द्वारा", + "from": "{{source}} से", + "viewSource": "देखें", + "viewOnSource": "{{source}} पर देखें" + } + }, + "sources": { + "title": "मार्केटप्लेस स्रोत कॉन्फ़िगर करें", + "description": "मार्केटप्लेस आइटम वाले Git रिपॉजिटरी जोड़ें। मार्केटप्लेस ब्राउज़ करते समय इन रिपॉजिटरी को फ़ेच किया जाएगा।", + "add": { + "title": "नया स्रोत जोड़ें", + "urlPlaceholder": "Git रिपॉजिटरी URL (उदा., https://github.com/username/repo)", + "urlFormats": "समर्थित प्रारूप: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git), या Git प्रोटोकॉल (git://github.com/username/repo.git)", + "namePlaceholder": "प्रदर्शन नाम (अधिकतम 20 अक्षर)", + "button": "स्रोत जोड़ें" + }, + "current": { + "title": "वर्तमान स्रोत", + "count": "{{current}}/{{max}} अधिकतम", + "empty": "कोई स्रोत कॉन्फ़िगर नहीं किया गया। शुरू करने के लिए एक स्रोत जोड़ें।", + "refresh": "इस स्रोत को रीफ्रेश करें", + "remove": "स्रोत हटाएं" + }, + "errors": { + "emptyUrl": "URL खाली नहीं हो सकता", + "invalidUrl": "अमान्य URL प्रारूप", + "nonVisibleChars": "URL में स्पेस के अलावा अदृश्य वर्ण हैं", + "invalidGitUrl": "URL एक वैध Git रिपॉजिटरी URL होना चाहिए (उदा., https://github.com/username/repo)", + "duplicateUrl": "यह URL पहले से सूची में है (केस और व्हाइटस्पेस असंवेदनशील मिलान)", + "nameTooLong": "नाम 20 अक्षरों या उससे कम का होना चाहिए", + "nonVisibleCharsName": "नाम में स्पेस के अलावा अदृश्य वर्ण हैं", + "duplicateName": "यह नाम पहले से उपयोग में है (केस और व्हाइटस्पेस असंवेदनशील मिलान)", + "maxSources": "अधिकतम {{max}} स्रोत की अनुमति है" + } + } +} diff --git a/webview-ui/src/i18n/locales/it/marketplace.json b/webview-ui/src/i18n/locales/it/marketplace.json new file mode 100644 index 0000000000..d507bd42f0 --- /dev/null +++ b/webview-ui/src/i18n/locales/it/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "Marketplace", + "tabs": { + "browse": "Sfoglia", + "sources": "Fonti" + }, + "filters": { + "search": { + "placeholder": "Cerca elementi del marketplace..." + }, + "type": { + "label": "Filtra per tipo:", + "all": "Tutti i tipi", + "mode": "Modalità", + "mcp": "Server MCP", + "prompt": "Prompt", + "package": "Pacchetto" + }, + "sort": { + "label": "Ordina per:", + "name": "Nome", + "author": "Autore", + "lastUpdated": "Ultimo aggiornamento" + }, + "tags": { + "label": "Filtra per tag:", + "available": "{{count}} disponibili", + "clear": "Cancella tag ({{count}})", + "placeholder": "Digita per cercare e selezionare i tag...", + "noResults": "Nessun tag corrispondente trovato", + "selected": "Mostrando elementi con uno qualsiasi dei tag selezionati ({{count}} selezionati)", + "clickToFilter": "Clicca sui tag per filtrare gli elementi" + } + }, + "type-group": { + "match": "Corrispondenza", + "modes": "Modalità", + "mcps": "Server MCP", + "prompts": "Prompt", + "packages": "Pacchetti", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "Nessun elemento trovato nel marketplace", + "withFilters": "Prova a modificare i filtri", + "noSources": "Prova ad aggiungere una fonte nella scheda Fonti" + }, + "count": "{{count}} elementi trovati", + "components": "{{count}} componenti", + "refresh": { + "button": "Aggiorna", + "refreshing": "Aggiornamento in corso..." + }, + "card": { + "by": "di {{author}}", + "from": "da {{source}}", + "viewSource": "Visualizza", + "viewOnSource": "Visualizza su {{source}}" + } + }, + "sources": { + "title": "Configura fonti del Marketplace", + "description": "Aggiungi repository Git che contengono elementi del marketplace. Questi repository verranno recuperati durante la navigazione nel marketplace.", + "add": { + "title": "Aggiungi nuova fonte", + "urlPlaceholder": "URL del repository Git (es., https://github.com/username/repo)", + "urlFormats": "Formati supportati: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) o protocollo Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nome visualizzato (max 20 caratteri)", + "button": "Aggiungi fonte" + }, + "current": { + "title": "Fonti attuali", + "count": "{{current}}/{{max}} max", + "empty": "Nessuna fonte configurata. Aggiungi una fonte per iniziare.", + "refresh": "Aggiorna questa fonte", + "remove": "Rimuovi fonte" + }, + "errors": { + "emptyUrl": "L'URL non può essere vuoto", + "invalidUrl": "Formato URL non valido", + "nonVisibleChars": "L'URL contiene caratteri non visibili diversi dagli spazi", + "invalidGitUrl": "L'URL deve essere un URL valido di un repository Git (es., https://github.com/username/repo)", + "duplicateUrl": "Questo URL è già presente nell'elenco (corrispondenza insensibile a maiuscole/minuscole e spazi)", + "nameTooLong": "Il nome deve essere di 20 caratteri o meno", + "nonVisibleCharsName": "Il nome contiene caratteri non visibili diversi dagli spazi", + "duplicateName": "Questo nome è già in uso (corrispondenza insensibile a maiuscole/minuscole e spazi)", + "maxSources": "Massimo {{max}} fonti consentite" + } + } +} diff --git a/webview-ui/src/i18n/locales/ja/marketplace.json b/webview-ui/src/i18n/locales/ja/marketplace.json new file mode 100644 index 0000000000..b3d12d1113 --- /dev/null +++ b/webview-ui/src/i18n/locales/ja/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "マーケットプレイス", + "tabs": { + "browse": "ブラウズ", + "sources": "ソース" + }, + "filters": { + "search": { + "placeholder": "マーケットプレイスのアイテムを検索..." + }, + "type": { + "label": "タイプでフィルター:", + "all": "すべてのタイプ", + "mode": "モード", + "mcp": "MCPサーバー", + "prompt": "プロンプト", + "package": "パッケージ" + }, + "sort": { + "label": "並び替え:", + "name": "名前", + "author": "作者", + "lastUpdated": "最終更新" + }, + "tags": { + "label": "タグでフィルター:", + "available": "{{count}}個利用可能", + "clear": "タグをクリア ({{count}})", + "placeholder": "タグを検索して選択...", + "noResults": "一致するタグが見つかりません", + "selected": "選択したタグのいずれかを含むアイテムを表示中 ({{count}}個選択)", + "clickToFilter": "タグをクリックしてアイテムをフィルター" + } + }, + "type-group": { + "match": "一致", + "modes": "モード", + "mcps": "MCPサーバー", + "prompts": "プロンプト", + "packages": "パッケージ", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "マーケットプレイスのアイテムが見つかりません", + "withFilters": "フィルターを調整してみてください", + "noSources": "ソースタブでソースを追加してみてください" + }, + "count": "{{count}}個のアイテムが見つかりました", + "components": "{{count}}個のコンポーネント", + "refresh": { + "button": "更新", + "refreshing": "更新中..." + }, + "card": { + "by": "作者: {{author}}", + "from": "ソース: {{source}}", + "viewSource": "表示", + "viewOnSource": "{{source}}で表示" + } + }, + "sources": { + "title": "マーケットプレイスのソースを設定", + "description": "マーケットプレイスのアイテムを含むGitリポジトリを追加します。これらのリポジトリはマーケットプレイスの閲覧時に取得されます。", + "add": { + "title": "新しいソースを追加", + "urlPlaceholder": "GitリポジトリのURL (例: https://github.com/username/repo)", + "urlFormats": "サポートされている形式: HTTPS (https://github.com/username/repo)、SSH (git@github.com:username/repo.git)、またはGitプロトコル (git://github.com/username/repo.git)", + "namePlaceholder": "表示名 (最大20文字)", + "button": "ソースを追加" + }, + "current": { + "title": "現在のソース", + "count": "{{current}}/{{max}}個まで", + "empty": "ソースが設定されていません。ソースを追加して始めましょう。", + "refresh": "このソースを更新", + "remove": "ソースを削除" + }, + "errors": { + "emptyUrl": "URLを入力してください", + "invalidUrl": "無効なURL形式です", + "nonVisibleChars": "URLにスペース以外の不可視文字が含まれています", + "invalidGitUrl": "URLは有効なGitリポジトリのURLである必要があります (例: https://github.com/username/repo)", + "duplicateUrl": "このURLは既にリストに存在します (大文字小文字とスペースを区別しない一致)", + "nameTooLong": "名前は20文字以内である必要があります", + "nonVisibleCharsName": "名前にスペース以外の不可視文字が含まれています", + "duplicateName": "この名前は既に使用されています (大文字小文字とスペースを区別しない一致)", + "maxSources": "最大{{max}}個のソースまで追加できます" + } + } +} diff --git a/webview-ui/src/i18n/locales/ko/marketplace.json b/webview-ui/src/i18n/locales/ko/marketplace.json new file mode 100644 index 0000000000..ab956fa56d --- /dev/null +++ b/webview-ui/src/i18n/locales/ko/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "마켓플레이스", + "tabs": { + "browse": "찾아보기", + "sources": "소스" + }, + "filters": { + "search": { + "placeholder": "마켓플레이스 항목 검색..." + }, + "type": { + "label": "유형별 필터:", + "all": "모든 유형", + "mode": "모드", + "mcp": "MCP 서버", + "prompt": "프롬프트", + "package": "패키지" + }, + "sort": { + "label": "정렬 기준:", + "name": "이름", + "author": "작성자", + "lastUpdated": "최근 업데이트" + }, + "tags": { + "label": "태그별 필터:", + "available": "{{count}}개 사용 가능", + "clear": "태그 지우기 ({{count}})", + "placeholder": "태그 검색 및 선택...", + "noResults": "일치하는 태그가 없습니다", + "selected": "선택한 태그가 있는 항목 표시 중 ({{count}}개 선택됨)", + "clickToFilter": "태그를 클릭하여 항목 필터링" + } + }, + "type-group": { + "match": "일치", + "modes": "모드", + "mcps": "MCP 서버", + "prompts": "프롬프트", + "packages": "패키지", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "마켓플레이스 항목을 찾을 수 없습니다", + "withFilters": "필터를 조정해 보세요", + "noSources": "소스 탭에서 소스를 추가해 보세요" + }, + "count": "{{count}}개 항목 찾음", + "components": "{{count}}개 구성 요소", + "refresh": { + "button": "새로 고침", + "refreshing": "새로 고치는 중..." + }, + "card": { + "by": "작성자: {{author}}", + "from": "출처: {{source}}", + "viewSource": "보기", + "viewOnSource": "{{source}}에서 보기" + } + }, + "sources": { + "title": "마켓플레이스 소스 구성", + "description": "마켓플레이스 항목이 포함된 Git 저장소를 추가합니다. 마켓플레이스를 탐색할 때 이러한 저장소를 가져옵니다.", + "add": { + "title": "새 소스 추가", + "urlPlaceholder": "Git 저장소 URL (예: https://github.com/username/repo)", + "urlFormats": "지원되는 형식: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) 또는 Git 프로토콜 (git://github.com/username/repo.git)", + "namePlaceholder": "표시 이름 (최대 20자)", + "button": "소스 추가" + }, + "current": { + "title": "현재 소스", + "count": "{{current}}/{{max}} 최대", + "empty": "구성된 소스가 없습니다. 소스를 추가하여 시작하세요.", + "refresh": "이 소스 새로 고침", + "remove": "소스 제거" + }, + "errors": { + "emptyUrl": "URL을 입력해야 합니다", + "invalidUrl": "잘못된 URL 형식", + "nonVisibleChars": "URL에 공백 이외의 보이지 않는 문자가 포함되어 있습니다", + "invalidGitUrl": "URL은 유효한 Git 저장소 URL이어야 합니다 (예: https://github.com/username/repo)", + "duplicateUrl": "이 URL은 이미 목록에 있습니다 (대소문자 및 공백 구분 없이 일치)", + "nameTooLong": "이름은 20자 이하여야 합니다", + "nonVisibleCharsName": "이름에 공백 이외의 보이지 않는 문자가 포함되어 있습니다", + "duplicateName": "이 이름은 이미 사용 중입니다 (대소문자 및 공백 구분 없이 일치)", + "maxSources": "최대 {{max}}개의 소스만 허용됩니다" + } + } +} diff --git a/webview-ui/src/i18n/locales/pl/marketplace.json b/webview-ui/src/i18n/locales/pl/marketplace.json new file mode 100644 index 0000000000..bcc75c150a --- /dev/null +++ b/webview-ui/src/i18n/locales/pl/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "Marketplace", + "tabs": { + "browse": "Przeglądaj", + "sources": "Źródła" + }, + "filters": { + "search": { + "placeholder": "Szukaj elementów marketplace..." + }, + "type": { + "label": "Filtruj według typu:", + "all": "Wszystkie typy", + "mode": "Tryb", + "mcp": "Serwer MCP", + "prompt": "Prompt", + "package": "Pakiet" + }, + "sort": { + "label": "Sortuj według:", + "name": "Nazwa", + "author": "Autor", + "lastUpdated": "Ostatnia aktualizacja" + }, + "tags": { + "label": "Filtruj według tagów:", + "available": "{{count}} dostępnych", + "clear": "Wyczyść tagi ({{count}})", + "placeholder": "Wpisz, aby wyszukać i wybrać tagi...", + "noResults": "Nie znaleziono pasujących tagów", + "selected": "Pokazywanie elementów z wybranymi tagami (wybrano {{count}})", + "clickToFilter": "Kliknij tagi, aby filtrować elementy" + } + }, + "type-group": { + "match": "Dopasowanie", + "modes": "Tryby", + "mcps": "Serwery MCP", + "prompts": "Prompty", + "packages": "Pakiety", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "Nie znaleziono elementów marketplace", + "withFilters": "Spróbuj dostosować filtry", + "noSources": "Spróbuj dodać źródło w zakładce Źródła" + }, + "count": "Znaleziono {{count}} elementów", + "components": "{{count}} komponentów", + "refresh": { + "button": "Odśwież", + "refreshing": "Odświeżanie..." + }, + "card": { + "by": "autor: {{author}}", + "from": "z {{source}}", + "viewSource": "Zobacz", + "viewOnSource": "Zobacz na {{source}}" + } + }, + "sources": { + "title": "Konfiguruj źródła Marketplace", + "description": "Dodaj repozytoria Git zawierające elementy marketplace. Te repozytoria będą pobierane podczas przeglądania marketplace.", + "add": { + "title": "Dodaj nowe źródło", + "urlPlaceholder": "URL repozytorium Git (np. https://github.com/username/repo)", + "urlFormats": "Obsługiwane formaty: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) lub protokół Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nazwa wyświetlana (maks. 20 znaków)", + "button": "Dodaj źródło" + }, + "current": { + "title": "Obecne źródła", + "count": "{{current}}/{{max}} maks.", + "empty": "Brak skonfigurowanych źródeł. Dodaj źródło, aby rozpocząć.", + "refresh": "Odśwież to źródło", + "remove": "Usuń źródło" + }, + "errors": { + "emptyUrl": "URL nie może być pusty", + "invalidUrl": "Nieprawidłowy format URL", + "nonVisibleChars": "URL zawiera niewidoczne znaki inne niż spacje", + "invalidGitUrl": "URL musi być prawidłowym URL-em repozytorium Git (np. https://github.com/username/repo)", + "duplicateUrl": "Ten URL już znajduje się na liście (dopasowanie niewrażliwe na wielkość liter i spacje)", + "nameTooLong": "Nazwa musi mieć 20 znaków lub mniej", + "nonVisibleCharsName": "Nazwa zawiera niewidoczne znaki inne niż spacje", + "duplicateName": "Ta nazwa jest już używana (dopasowanie niewrażliwe na wielkość liter i spacje)", + "maxSources": "Maksymalnie dozwolone {{max}} źródeł" + } + } +} diff --git a/webview-ui/src/i18n/locales/pt-BR/marketplace.json b/webview-ui/src/i18n/locales/pt-BR/marketplace.json new file mode 100644 index 0000000000..2bddc25882 --- /dev/null +++ b/webview-ui/src/i18n/locales/pt-BR/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "Marketplace", + "tabs": { + "browse": "Explorar", + "sources": "Fontes" + }, + "filters": { + "search": { + "placeholder": "Pesquisar itens do marketplace..." + }, + "type": { + "label": "Filtrar por tipo:", + "all": "Todos os tipos", + "mode": "Modo", + "mcp": "Servidor MCP", + "prompt": "Prompt", + "package": "Pacote" + }, + "sort": { + "label": "Ordenar por:", + "name": "Nome", + "author": "Autor", + "lastUpdated": "Última atualização" + }, + "tags": { + "label": "Filtrar por tags:", + "available": "{{count}} disponíveis", + "clear": "Limpar tags ({{count}})", + "placeholder": "Digite para pesquisar e selecionar tags...", + "noResults": "Nenhuma tag correspondente encontrada", + "selected": "Mostrando itens com qualquer uma das tags selecionadas ({{count}} selecionadas)", + "clickToFilter": "Clique nas tags para filtrar itens" + } + }, + "type-group": { + "match": "Correspondência", + "modes": "Modos", + "mcps": "Servidores MCP", + "prompts": "Prompts", + "packages": "Pacotes", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "Nenhum item encontrado no marketplace", + "withFilters": "Tente ajustar os filtros", + "noSources": "Tente adicionar uma fonte na aba Fontes" + }, + "count": "{{count}} itens encontrados", + "components": "{{count}} componentes", + "refresh": { + "button": "Atualizar", + "refreshing": "Atualizando..." + }, + "card": { + "by": "por {{author}}", + "from": "de {{source}}", + "viewSource": "Ver", + "viewOnSource": "Ver no {{source}}" + } + }, + "sources": { + "title": "Configurar Fontes do Marketplace", + "description": "Adicione repositórios Git que contenham itens do marketplace. Estes repositórios serão buscados ao navegar pelo marketplace.", + "add": { + "title": "Adicionar Nova Fonte", + "urlPlaceholder": "URL do repositório Git (ex., https://github.com/username/repo)", + "urlFormats": "Formatos suportados: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) ou protocolo Git (git://github.com/username/repo.git)", + "namePlaceholder": "Nome de exibição (máx. 20 caracteres)", + "button": "Adicionar Fonte" + }, + "current": { + "title": "Fontes Atuais", + "count": "{{current}}/{{max}} máx.", + "empty": "Nenhuma fonte configurada. Adicione uma fonte para começar.", + "refresh": "Atualizar esta fonte", + "remove": "Remover fonte" + }, + "errors": { + "emptyUrl": "A URL não pode estar vazia", + "invalidUrl": "Formato de URL inválido", + "nonVisibleChars": "A URL contém caracteres não visíveis além de espaços", + "invalidGitUrl": "A URL deve ser uma URL válida de repositório Git (ex., https://github.com/username/repo)", + "duplicateUrl": "Esta URL já está na lista (correspondência insensível a maiúsculas/minúsculas e espaços)", + "nameTooLong": "O nome deve ter 20 caracteres ou menos", + "nonVisibleCharsName": "O nome contém caracteres não visíveis além de espaços", + "duplicateName": "Este nome já está em uso (correspondência insensível a maiúsculas/minúsculas e espaços)", + "maxSources": "Máximo de {{max}} fontes permitidas" + } + } +} diff --git a/webview-ui/src/i18n/locales/tr/marketplace.json b/webview-ui/src/i18n/locales/tr/marketplace.json new file mode 100644 index 0000000000..47193ae4bc --- /dev/null +++ b/webview-ui/src/i18n/locales/tr/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "Marketplace", + "tabs": { + "browse": "Göz At", + "sources": "Kaynaklar" + }, + "filters": { + "search": { + "placeholder": "Marketplace öğelerini ara..." + }, + "type": { + "label": "Türe göre filtrele:", + "all": "Tüm türler", + "mode": "Mod", + "mcp": "MCP Sunucusu", + "prompt": "Prompt", + "package": "Paket" + }, + "sort": { + "label": "Sırala:", + "name": "İsim", + "author": "Yazar", + "lastUpdated": "Son güncelleme" + }, + "tags": { + "label": "Etiketlere göre filtrele:", + "available": "{{count}} mevcut", + "clear": "Etiketleri temizle ({{count}})", + "placeholder": "Etiket aramak ve seçmek için yazın...", + "noResults": "Eşleşen etiket bulunamadı", + "selected": "Seçili etiketlerden herhangi birine sahip öğeler gösteriliyor ({{count}} seçili)", + "clickToFilter": "Öğeleri filtrelemek için etiketlere tıklayın" + } + }, + "type-group": { + "match": "Eşleşme", + "modes": "Modlar", + "mcps": "MCP Sunucuları", + "prompts": "Promptlar", + "packages": "Paketler", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "Marketplace öğesi bulunamadı", + "withFilters": "Filtreleri ayarlamayı deneyin", + "noSources": "Kaynaklar sekmesinde bir kaynak eklemeyi deneyin" + }, + "count": "{{count}} öğe bulundu", + "components": "{{count}} bileşen", + "refresh": { + "button": "Yenile", + "refreshing": "Yenileniyor..." + }, + "card": { + "by": "yazar: {{author}}", + "from": "kaynak: {{source}}", + "viewSource": "Görüntüle", + "viewOnSource": "{{source}} üzerinde görüntüle" + } + }, + "sources": { + "title": "Marketplace Kaynaklarını Yapılandır", + "description": "Marketplace öğeleri içeren Git depolarını ekleyin. Bu depolar marketplace'de gezinirken getirilecektir.", + "add": { + "title": "Yeni Kaynak Ekle", + "urlPlaceholder": "Git deposu URL'si (örn., https://github.com/username/repo)", + "urlFormats": "Desteklenen formatlar: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) veya Git protokolü (git://github.com/username/repo.git)", + "namePlaceholder": "Görünen ad (maks. 20 karakter)", + "button": "Kaynak Ekle" + }, + "current": { + "title": "Mevcut Kaynaklar", + "count": "{{current}}/{{max}} maks.", + "empty": "Yapılandırılmış kaynak yok. Başlamak için bir kaynak ekleyin.", + "refresh": "Bu kaynağı yenile", + "remove": "Kaynağı kaldır" + }, + "errors": { + "emptyUrl": "URL boş olamaz", + "invalidUrl": "Geçersiz URL formatı", + "nonVisibleChars": "URL boşluk dışında görünmeyen karakterler içeriyor", + "invalidGitUrl": "URL geçerli bir Git deposu URL'si olmalıdır (örn., https://github.com/username/repo)", + "duplicateUrl": "Bu URL zaten listede (büyük/küçük harf ve boşluk duyarsız eşleşme)", + "nameTooLong": "İsim 20 karakter veya daha az olmalıdır", + "nonVisibleCharsName": "İsim boşluk dışında görünmeyen karakterler içeriyor", + "duplicateName": "Bu isim zaten kullanımda (büyük/küçük harf ve boşluk duyarsız eşleşme)", + "maxSources": "Maksimum {{max}} kaynak izin veriliyor" + } + } +} diff --git a/webview-ui/src/i18n/locales/vi/marketplace.json b/webview-ui/src/i18n/locales/vi/marketplace.json new file mode 100644 index 0000000000..098ea878d6 --- /dev/null +++ b/webview-ui/src/i18n/locales/vi/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "Marketplace", + "tabs": { + "browse": "Duyệt", + "sources": "Nguồn" + }, + "filters": { + "search": { + "placeholder": "Tìm kiếm các mục marketplace..." + }, + "type": { + "label": "Lọc theo loại:", + "all": "Tất cả các loại", + "mode": "Chế độ", + "mcp": "Máy chủ MCP", + "prompt": "Prompt", + "package": "Gói" + }, + "sort": { + "label": "Sắp xếp theo:", + "name": "Tên", + "author": "Tác giả", + "lastUpdated": "Cập nhật lần cuối" + }, + "tags": { + "label": "Lọc theo thẻ:", + "available": "{{count}} có sẵn", + "clear": "Xóa thẻ ({{count}})", + "placeholder": "Gõ để tìm kiếm và chọn thẻ...", + "noResults": "Không tìm thấy thẻ phù hợp", + "selected": "Hiển thị các mục có bất kỳ thẻ đã chọn nào (đã chọn {{count}})", + "clickToFilter": "Nhấp vào thẻ để lọc các mục" + } + }, + "type-group": { + "match": "Khớp", + "modes": "Chế độ", + "mcps": "Máy chủ MCP", + "prompts": "Prompt", + "packages": "Gói", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "Không tìm thấy mục marketplace nào", + "withFilters": "Thử điều chỉnh bộ lọc", + "noSources": "Thử thêm nguồn trong tab Nguồn" + }, + "count": "Tìm thấy {{count}} mục", + "components": "{{count}} thành phần", + "refresh": { + "button": "Làm mới", + "refreshing": "Đang làm mới..." + }, + "card": { + "by": "bởi {{author}}", + "from": "từ {{source}}", + "viewSource": "Xem", + "viewOnSource": "Xem trên {{source}}" + } + }, + "sources": { + "title": "Cấu hình Nguồn Marketplace", + "description": "Thêm kho Git chứa các mục marketplace. Các kho này sẽ được tải khi duyệt marketplace.", + "add": { + "title": "Thêm Nguồn Mới", + "urlPlaceholder": "URL kho Git (ví dụ: https://github.com/username/repo)", + "urlFormats": "Định dạng được hỗ trợ: HTTPS (https://github.com/username/repo), SSH (git@github.com:username/repo.git) hoặc giao thức Git (git://github.com/username/repo.git)", + "namePlaceholder": "Tên hiển thị (tối đa 20 ký tự)", + "button": "Thêm Nguồn" + }, + "current": { + "title": "Nguồn Hiện Tại", + "count": "{{current}}/{{max}} tối đa", + "empty": "Chưa có nguồn nào được cấu hình. Thêm một nguồn để bắt đầu.", + "refresh": "Làm mới nguồn này", + "remove": "Xóa nguồn" + }, + "errors": { + "emptyUrl": "URL không được để trống", + "invalidUrl": "Định dạng URL không hợp lệ", + "nonVisibleChars": "URL chứa ký tự không nhìn thấy ngoài dấu cách", + "invalidGitUrl": "URL phải là URL kho Git hợp lệ (ví dụ: https://github.com/username/repo)", + "duplicateUrl": "URL này đã có trong danh sách (khớp không phân biệt chữ hoa/thường và dấu cách)", + "nameTooLong": "Tên phải có 20 ký tự trở xuống", + "nonVisibleCharsName": "Tên chứa ký tự không nhìn thấy ngoài dấu cách", + "duplicateName": "Tên này đã được sử dụng (khớp không phân biệt chữ hoa/thường và dấu cách)", + "maxSources": "Cho phép tối đa {{max}} nguồn" + } + } +} diff --git a/webview-ui/src/i18n/locales/zh-CN/marketplace.json b/webview-ui/src/i18n/locales/zh-CN/marketplace.json new file mode 100644 index 0000000000..14860a74fb --- /dev/null +++ b/webview-ui/src/i18n/locales/zh-CN/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "市场", + "tabs": { + "browse": "浏览", + "sources": "来源" + }, + "filters": { + "search": { + "placeholder": "搜索市场项目..." + }, + "type": { + "label": "按类型筛选:", + "all": "所有类型", + "mode": "模式", + "mcp": "MCP 服务器", + "prompt": "提示", + "package": "包" + }, + "sort": { + "label": "排序方式:", + "name": "名称", + "author": "作者", + "lastUpdated": "最近更新" + }, + "tags": { + "label": "按标签筛选:", + "available": "{{count}} 个可用", + "clear": "清除标签 ({{count}})", + "placeholder": "输入以搜索和选择标签...", + "noResults": "未找到匹配的标签", + "selected": "显示具有任何已选标签的项目(已选 {{count}} 个)", + "clickToFilter": "点击标签以筛选项目" + } + }, + "type-group": { + "match": "匹配", + "modes": "模式", + "mcps": "MCP 服务器", + "prompts": "提示", + "packages": "包", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "未找到市场项目", + "withFilters": "尝试调整筛选条件", + "noSources": "尝试在来源标签页中添加来源" + }, + "count": "找到 {{count}} 个项目", + "components": "{{count}} 个组件", + "refresh": { + "button": "刷新", + "refreshing": "正在刷新..." + }, + "card": { + "by": "作者:{{author}}", + "from": "来自:{{source}}", + "viewSource": "查看", + "viewOnSource": "在 {{source}} 上查看" + } + }, + "sources": { + "title": "配置市场来源", + "description": "添加包含市场项目的 Git 仓库。浏览市场时将获取这些仓库。", + "add": { + "title": "添加新来源", + "urlPlaceholder": "Git 仓库 URL(例如:https://github.com/username/repo)", + "urlFormats": "支持的格式:HTTPS (https://github.com/username/repo)、SSH (git@github.com:username/repo.git) 或 Git 协议 (git://github.com/username/repo.git)", + "namePlaceholder": "显示名称(最多 20 个字符)", + "button": "添加来源" + }, + "current": { + "title": "当前来源", + "count": "{{current}}/{{max}} 个上限", + "empty": "未配置来源。添加一个来源以开始。", + "refresh": "刷新此来源", + "remove": "移除来源" + }, + "errors": { + "emptyUrl": "URL 不能为空", + "invalidUrl": "无效的 URL 格式", + "nonVisibleChars": "URL 包含空格以外的不可见字符", + "invalidGitUrl": "URL 必须是有效的 Git 仓库 URL(例如:https://github.com/username/repo)", + "duplicateUrl": "此 URL 已在列表中(不区分大小写和空格的匹配)", + "nameTooLong": "名称必须不超过 20 个字符", + "nonVisibleCharsName": "名称包含空格以外的不可见字符", + "duplicateName": "此名称已被使用(不区分大小写和空格的匹配)", + "maxSources": "最多允许 {{max}} 个来源" + } + } +} diff --git a/webview-ui/src/i18n/locales/zh-TW/marketplace.json b/webview-ui/src/i18n/locales/zh-TW/marketplace.json new file mode 100644 index 0000000000..f295d245f3 --- /dev/null +++ b/webview-ui/src/i18n/locales/zh-TW/marketplace.json @@ -0,0 +1,91 @@ +{ + "title": "市集", + "tabs": { + "browse": "瀏覽", + "sources": "來源" + }, + "filters": { + "search": { + "placeholder": "搜尋市集項目..." + }, + "type": { + "label": "依類型篩選:", + "all": "所有類型", + "mode": "模式", + "mcp": "MCP 伺服器", + "prompt": "提示", + "package": "套件" + }, + "sort": { + "label": "排序方式:", + "name": "名稱", + "author": "作者", + "lastUpdated": "最近更新" + }, + "tags": { + "label": "依標籤篩選:", + "available": "{{count}} 個可用", + "clear": "清除標籤 ({{count}})", + "placeholder": "輸入以搜尋和選擇標籤...", + "noResults": "未找到符合的標籤", + "selected": "顯示具有任何已選標籤的項目(已選 {{count}} 個)", + "clickToFilter": "點擊標籤以篩選項目" + } + }, + "type-group": { + "match": "符合", + "modes": "模式", + "mcps": "MCP 伺服器", + "prompts": "提示", + "packages": "套件", + "generic-type": "{{type}}" + }, + "items": { + "empty": { + "noItems": "未找到市集項目", + "withFilters": "嘗試調整篩選條件", + "noSources": "嘗試在來源分頁中新增來源" + }, + "count": "找到 {{count}} 個項目", + "components": "{{count}} 個元件", + "refresh": { + "button": "重新整理", + "refreshing": "重新整理中..." + }, + "card": { + "by": "作者:{{author}}", + "from": "來自:{{source}}", + "viewSource": "檢視", + "viewOnSource": "在 {{source}} 上檢視" + } + }, + "sources": { + "title": "設定市集來源", + "description": "新增包含市集項目的 Git 儲存庫。瀏覽市集時將擷取這些儲存庫。", + "add": { + "title": "新增來源", + "urlPlaceholder": "Git 儲存庫 URL(例如:https://github.com/username/repo)", + "urlFormats": "支援的格式:HTTPS (https://github.com/username/repo)、SSH (git@github.com:username/repo.git) 或 Git 協定 (git://github.com/username/repo.git)", + "namePlaceholder": "顯示名稱(最多 20 個字元)", + "button": "新增來源" + }, + "current": { + "title": "目前來源", + "count": "{{current}}/{{max}} 個上限", + "empty": "未設定來源。新增一個來源以開始。", + "refresh": "重新整理此來源", + "remove": "移除來源" + }, + "errors": { + "emptyUrl": "URL 不能為空", + "invalidUrl": "無效的 URL 格式", + "nonVisibleChars": "URL 包含空格以外的不可見字元", + "invalidGitUrl": "URL 必須是有效的 Git 儲存庫 URL(例如:https://github.com/username/repo)", + "duplicateUrl": "此 URL 已在清單中(不區分大小寫和空格的符合)", + "nameTooLong": "名稱必須不超過 20 個字元", + "nonVisibleCharsName": "名稱包含空格以外的不可見字元", + "duplicateName": "此名稱已被使用(不區分大小寫和空格的符合)", + "maxSources": "最多允許 {{max}} 個來源" + } + } +} diff --git a/webview-ui/src/i18n/test-utils.ts b/webview-ui/src/i18n/test-utils.ts index 9abd4d9e06..daad16bdea 100644 --- a/webview-ui/src/i18n/test-utils.ts +++ b/webview-ui/src/i18n/test-utils.ts @@ -29,6 +29,25 @@ export const setupI18nForTests = () => { chat: { test: "Test", }, + marketplace: { + items: { + card: { + by: "by {{author}}", + viewSource: "View", + externalComponents: "Contains {{count}} external component", + externalComponents_plural: "Contains {{count}} external components", + }, + }, + filters: { + type: { + package: "Package", + mode: "Mode", + }, + tags: { + clickToFilter: "Click tags to filter items", + }, + }, + }, }, }, }) diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index 99fb05435d..5405d77642 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -123,6 +123,27 @@ --color-vscode-inputValidation-infoForeground: var(--vscode-inputValidation-infoForeground); --color-vscode-inputValidation-infoBackground: var(--vscode-inputValidation-infoBackground); --color-vscode-inputValidation-infoBorder: var(--vscode-inputValidation-infoBorder); + + @keyframes accordion-down { + 0% { + height: 0; + } + 100% { + height: var(--radix-accordion-content-height); + } + } + + @keyframes accordion-up { + 0% { + height: var(--radix-accordion-content-height); + } + 100% { + height: 0; + } + } + + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; } @layer base { @@ -414,10 +435,59 @@ input[cmdk-input]:focus { text-rendering: geometricPrecision !important; } -/* - * Fix the color of in ChatView +/** + * Custom animations for UI elements */ -a:focus { - outline: 1px solid var(--vscode-focusBorder); +@keyframes slide-in-right { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +.animate-slide-in-right { + animation: slide-in-right 0.3s ease-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.animate-fade-in { + animation: fade-in 0.2s ease-out; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +.animate-pulse { + animation: pulse 1.5s ease-in-out infinite; +} + +/* Transition utilities */ +.transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; } diff --git a/webview-ui/src/test/test-utils.tsx b/webview-ui/src/test/test-utils.tsx new file mode 100644 index 0000000000..372783bd5f --- /dev/null +++ b/webview-ui/src/test/test-utils.tsx @@ -0,0 +1,107 @@ +import React from "react" +import { render } from "@testing-library/react" +import { TranslationProvider } from "@/i18n/TranslationContext" +import { ExtensionStateContext } from "@/context/ExtensionStateContext" +import i18next from "i18next" +import { initReactI18next } from "react-i18next" + +// Mock vscode API +;(global as any).acquireVsCodeApi = () => ({ + postMessage: jest.fn(), +}) + +// Initialize i18next for tests +i18next.use(initReactI18next).init({ + lng: "en", + fallbackLng: "en", + interpolation: { + escapeValue: false, + }, + resources: { + en: { + marketplace: { + title: "Marketplace", + tabs: { + browse: "Browse", + sources: "Sources", + }, + filters: { + search: { + placeholder: "Search marketplace items...", + }, + type: { + label: "Filter by type:", + all: "All types", + package: "Package", + mode: "Mode", + mcp: "MCP Server", + prompt: "Prompt", + }, + sort: { + label: "Sort by:", + name: "Name", + author: "Author", + lastUpdated: "Last Updated", + }, + tags: { + label: "Filter by tags:", + available: "{{count}} available", + clear: "Clear tags ({{count}})", + placeholder: "Type to search and select tags...", + noResults: "No matching tags found", + selected: "Showing items with any of the selected tags ({{count}} selected)", + clickToFilter: "Click tags to filter items", + }, + }, + items: { + empty: { + noItems: "No marketplace items found", + withFilters: "Try adjusting your filters", + noSources: "Try adding a source in the Sources tab", + }, + count: "{{count}} items found", + components: "{{count}} components", + refresh: { + button: "Refresh", + refreshing: "Refreshing...", + }, + card: { + by: "by {{author}}", + from: "from {{source}}", + viewSource: "View", + viewOnSource: "View on {{source}}", + actionsMenuLabel: "Actions", + }, + }, + "type-group": { + mcps: "MCP Servers", + modes: "Modes", + prompts: "Prompts", + packages: "Packages", + match: "Match", + "generic-type": "{{type}}s", + }, + }, + }, + }, +}) + +// Minimal mock state +const mockExtensionState = { + language: "en", + marketplaceSources: [{ url: "test-url", enabled: true }], + setMarketplaceSources: jest.fn(), + experiments: { + search_and_replace: false, + insert_content: false, + powerSteering: false, + }, +} + +export const renderWithProviders = (ui: React.ReactElement) => { + return render( + + {ui} + , + ) +} From 35c438dbd01a2e301bf32b44a5ef8bac48c1c4ad Mon Sep 17 00:00:00 2001 From: NamesMT Date: Wed, 21 May 2025 16:38:33 +0000 Subject: [PATCH 02/23] fix: compatibility with latest version --- src/core/webview/marketplaceMessageHandler.ts | 26 ++++--------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/core/webview/marketplaceMessageHandler.ts b/src/core/webview/marketplaceMessageHandler.ts index 06afcebef7..31b90d3d83 100644 --- a/src/core/webview/marketplaceMessageHandler.ts +++ b/src/core/webview/marketplaceMessageHandler.ts @@ -104,11 +104,7 @@ export async function handleMarketplaceMessages( case "fetchMarketplaceItems": { // Prevent multiple simultaneous fetches if (marketplaceManager.isFetching) { - await provider.postMessageToWebview({ - type: "state", - text: "Fetch already in progress", - }) - marketplaceManager.isFetching = false + console.warn("Fetch already in progress") return true } @@ -149,41 +145,29 @@ export async function handleMarketplaceMessages( else if (result.errors && result.items.length === 0) { const errorMessage = `Failed to load marketplace sources:\n${result.errors.join("\n")}` vscode.window.showErrorMessage(errorMessage) - await provider.postMessageToWebview({ - type: "state", - text: errorMessage, - }) - marketplaceManager.isFetching = false } // The items are already stored in MarketplaceManager's currentItems // No need to store in global state - // Send state to webview + // Is done, send state to webview + marketplaceManager.isFetching = false await provider.postStateToWebview() return true } catch (initError) { const errorMessage = `Marketplace initialization failed: ${initError instanceof Error ? initError.message : String(initError)}` console.error("Error in marketplace initialization:", initError) - vscode.window.showErrorMessage(errorMessage) - await provider.postMessageToWebview({ - type: "state", - text: errorMessage, - }) // The state will already be updated with empty items by MarketplaceManager - await provider.postStateToWebview() + vscode.window.showErrorMessage(errorMessage) marketplaceManager.isFetching = false + await provider.postStateToWebview() return false } } catch (error) { const errorMessage = `Failed to fetch marketplace items: ${error instanceof Error ? error.message : String(error)}` console.error("Failed to fetch marketplace items:", error) vscode.window.showErrorMessage(errorMessage) - await provider.postMessageToWebview({ - type: "state", - text: errorMessage, - }) marketplaceManager.isFetching = false return false } From 4c417217b009d38f17635bf584f2cfcc60a4e474 Mon Sep 17 00:00:00 2001 From: NamesMT Date: Wed, 21 May 2025 16:49:50 +0000 Subject: [PATCH 03/23] chore: apply org change and re-add contributors Co-authored-by: Matt Rubens Co-authored-by: Smartsheet-JB-Brown Co-authored-by: elianiva <51877647+elianiva@users.noreply.github.com> --- cline_docs/marketplace/README.md | 2 +- cline_docs/marketplace/user-guide/05-adding-packages.md | 4 ++-- .../marketplace/user-guide/06-adding-custom-sources.md | 2 +- .../__tests__/MetadataScanner.external.test.ts | 2 +- src/services/marketplace/constants.ts | 2 +- .../__tests__/MarketplaceViewStateManager.test.ts | 8 ++++---- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cline_docs/marketplace/README.md b/cline_docs/marketplace/README.md index 6e43e25723..2d504d2175 100644 --- a/cline_docs/marketplace/README.md +++ b/cline_docs/marketplace/README.md @@ -37,7 +37,7 @@ The Marketplace provides the following key features: ## Default Marketplace Repository The default Marketplace repository is located at: -[https://github.com/RooVetGit/Roo-Code-Marketplace](https://github.com/RooVetGit/Roo-Code-Marketplace) +[https://github.com/RooCodeInc/Roo-Code-Marketplace](https://github.com/RooCodeInc/Roo-Code-Marketplace) ## Contributing diff --git a/cline_docs/marketplace/user-guide/05-adding-packages.md b/cline_docs/marketplace/user-guide/05-adding-packages.md index 55ebd13e37..f048f23efc 100644 --- a/cline_docs/marketplace/user-guide/05-adding-packages.md +++ b/cline_docs/marketplace/user-guide/05-adding-packages.md @@ -97,7 +97,7 @@ To contribute your package to the official repository, follow these steps: ### 1. Fork the Repository -1. Visit the official Roo Code Packages repository: [https://github.com/RooVetGit/Roo-Code-Marketplace](https://github.com/RooVetGit/Roo-Code-Marketplace) +1. Visit the official Roo Code Packages repository: [https://github.com/RooCodeInc/Roo-Code-Marketplace](https://github.com/RooCodeInc/Roo-Code-Marketplace) 2. Click the "Fork" button in the top-right corner 3. This creates your own copy of the repository where you can make changes @@ -149,7 +149,7 @@ git push origin main ### 6. Create a Pull Request -1. Go to the original repository: [https://github.com/RooVetGit/Roo-Code-Marketplace](https://github.com/RooVetGit/Roo-Code-Marketplace) +1. Go to the original repository: [https://github.com/RooCodeInc/Roo-Code-Marketplace](https://github.com/RooCodeInc/Roo-Code-Marketplace) 2. Click "Pull Requests" and then "New Pull Request" 3. Click "Compare across forks" 4. Select your fork as the head repository diff --git a/cline_docs/marketplace/user-guide/06-adding-custom-sources.md b/cline_docs/marketplace/user-guide/06-adding-custom-sources.md index 7baf639631..2e75f2e0f2 100644 --- a/cline_docs/marketplace/user-guide/06-adding-custom-sources.md +++ b/cline_docs/marketplace/user-guide/06-adding-custom-sources.md @@ -60,7 +60,7 @@ Once you have a properly structured source repository, you can add it to your Ro Roo Code comes with a default package source: -- URL: `https://github.com/RooVetGit/Roo-Code-Marketplace` +- URL: `https://github.com/RooCodeInc/Roo-Code-Marketplace` - This source is enabled by default, and anytime all sources have been deleted. ### Adding a New Source diff --git a/src/services/marketplace/__tests__/MetadataScanner.external.test.ts b/src/services/marketplace/__tests__/MetadataScanner.external.test.ts index a06acac1bb..8f3b78b386 100644 --- a/src/services/marketplace/__tests__/MetadataScanner.external.test.ts +++ b/src/services/marketplace/__tests__/MetadataScanner.external.test.ts @@ -13,7 +13,7 @@ describe("MetadataScanner External References", () => { const gitFetcher = new GitFetcher(mockContext) // Fetch the marketplace repository - const repoUrl = "https://github.com/RooVetGit/Roo-Code-Marketplace" + const repoUrl = "https://github.com/RooCodeInc/Roo-Code-Marketplace" const repo = await gitFetcher.fetchRepository(repoUrl) // Find the Project Manager package diff --git a/src/services/marketplace/constants.ts b/src/services/marketplace/constants.ts index bf71234bcf..4b467563d2 100644 --- a/src/services/marketplace/constants.ts +++ b/src/services/marketplace/constants.ts @@ -5,7 +5,7 @@ /** * Default marketplace repository URL */ -export const DEFAULT_PACKAGE_MANAGER_REPO_URL = "https://github.com/RooVetGit/Roo-Code-Marketplace" +export const DEFAULT_PACKAGE_MANAGER_REPO_URL = "https://github.com/RooCodeInc/Roo-Code-Marketplace" /** * Default marketplace repository name diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts b/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts index e6fbca68a5..01a9712219 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts @@ -82,7 +82,7 @@ describe("MarketplaceViewStateManager", () => { const state = manager.getState() expect(state.sources).toEqual([ { - url: "https://github.com/RooVetGit/Roo-Code-Marketplace", + url: "https://github.com/RooCodeInc/Roo-Code-Marketplace", name: "Roo Code", enabled: true, }, @@ -93,7 +93,7 @@ describe("MarketplaceViewStateManager", () => { type: "marketplaceSources", sources: [ { - url: "https://github.com/RooVetGit/Roo-Code-Marketplace", + url: "https://github.com/RooCodeInc/Roo-Code-Marketplace", name: "Roo Code", enabled: true, }, @@ -896,7 +896,7 @@ describe("MarketplaceViewStateManager", () => { type: "marketplaceSources", sources: [ { - url: "https://github.com/RooVetGit/Roo-Code-Marketplace", + url: "https://github.com/RooCodeInc/Roo-Code-Marketplace", name: "Roo Code", enabled: true, }, @@ -1128,7 +1128,7 @@ describe("MarketplaceViewStateManager", () => { it("should restore sources from marketplaceSources on webview launch", () => { const savedSources = [ { - url: "https://github.com/RooVetGit/Roo-Code-Marketplace", + url: "https://github.com/RooCodeInc/Roo-Code-Marketplace", name: "Roo Code", enabled: true, }, From eecb53b7d12f8f8d88ffcfc6493ecb1c83e8f231 Mon Sep 17 00:00:00 2001 From: Trung Dang Date: Thu, 22 May 2025 11:41:18 +0700 Subject: [PATCH 04/23] refactor(marketplace): some UI adjustments (#13) * refactor(marketplace): add installed tabs * fix: missing settings button * refactor(marketplace): better card UI * refactor(marketplace): better error message for sources * tests(marketplace): item card and source config * refactor(marketplace): colocate local states * refactor(marketplace): simplify tabs * test: marketplace view --------- Co-authored-by: elianiva <51877647+elianiva@users.noreply.github.com> --- src/i18n/locales/en/marketplace.json | 20 +- .../marketplace/MarketplaceListView.tsx | 33 +- .../MarketplaceSourcesConfigView.tsx | 155 +++++- .../marketplace/MarketplaceView.tsx | 106 ++-- .../MarketplaceViewStateManager.ts | 6 +- .../__tests__/MarketplaceListView.test.tsx | 30 +- .../MarketplaceSourcesConfig.test.tsx | 161 +++++- .../__tests__/MarketplaceView.test.tsx | 480 ++++++++++++++++++ .../components/MarketplaceItemActionsMenu.tsx | 52 +- .../components/MarketplaceItemCard.tsx | 114 +++-- .../__tests__/MarketplaceItemCard.test.tsx | 227 ++++++++- .../src/i18n/locales/ca/marketplace.json | 60 ++- .../src/i18n/locales/de/marketplace.json | 67 ++- .../src/i18n/locales/en/marketplace.json | 19 +- .../src/i18n/locales/es/marketplace.json | 67 ++- .../src/i18n/locales/fr/marketplace.json | 67 ++- .../src/i18n/locales/hi/marketplace.json | 65 ++- .../src/i18n/locales/it/marketplace.json | 59 ++- .../src/i18n/locales/ja/marketplace.json | 61 ++- .../src/i18n/locales/ko/marketplace.json | 61 ++- .../src/i18n/locales/nl/marketplace.json | 102 ++++ .../src/i18n/locales/pl/marketplace.json | 59 ++- .../src/i18n/locales/pt-BR/marketplace.json | 52 +- .../src/i18n/locales/ru/marketplace.json | 101 ++++ .../src/i18n/locales/tr/marketplace.json | 58 ++- .../src/i18n/locales/vi/marketplace.json | 52 +- .../src/i18n/locales/zh-CN/marketplace.json | 52 +- .../src/i18n/locales/zh-TW/marketplace.json | 52 +- 28 files changed, 1888 insertions(+), 550 deletions(-) create mode 100644 webview-ui/src/components/marketplace/__tests__/MarketplaceView.test.tsx create mode 100644 webview-ui/src/i18n/locales/nl/marketplace.json create mode 100644 webview-ui/src/i18n/locales/ru/marketplace.json diff --git a/src/i18n/locales/en/marketplace.json b/src/i18n/locales/en/marketplace.json index c58b121f5b..7d9728b03a 100644 --- a/src/i18n/locales/en/marketplace.json +++ b/src/i18n/locales/en/marketplace.json @@ -27,11 +27,6 @@ "installButton": "Install", "cancelButton": "Cancel" }, - "install-sidebar": { - "title": "Install {{itemName}}", - "installButton": "Install", - "cancelButton": "Cancel" - }, "filters": { "search": { "placeholder": "Search marketplace..." @@ -78,19 +73,20 @@ "current": { "title": "Current Sources", "empty": "No marketplace sources added yet.", - "emptyHint": "Add a source above to browse marketplace items." - }, - "current": { + "emptyHint": "Add a source above to browse marketplace items.", "refresh": "Refresh source", "remove": "Remove source" } }, - "tabs": { - "browse": "Browse", - "sources": "Sources" - }, "title": "Marketplace" }, + "done": "Done", + "refresh": "Refresh", + "tabs": { + "installed": "Installed", + "browse": "Browse", + "settings": "Settings" + }, "items": { "refresh": { "refreshing": "Refreshing marketplace items..." diff --git a/webview-ui/src/components/marketplace/MarketplaceListView.tsx b/webview-ui/src/components/marketplace/MarketplaceListView.tsx index 4f5198530d..83f0d22251 100644 --- a/webview-ui/src/components/marketplace/MarketplaceListView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceListView.tsx @@ -1,3 +1,4 @@ +import * as React from "react" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Button } from "@/components/ui/button" @@ -13,24 +14,23 @@ export interface MarketplaceListViewProps { stateManager: MarketplaceViewStateManager allTags: string[] filteredTags: string[] - tagSearch: string - setTagSearch: (value: string) => void - isTagPopoverOpen: boolean - setIsTagPopoverOpen: (value: boolean) => void + showInstalledOnly?: boolean } export function MarketplaceListView({ stateManager, allTags, filteredTags, - tagSearch, - setTagSearch, - isTagPopoverOpen, - setIsTagPopoverOpen, + showInstalledOnly = false, }: MarketplaceListViewProps) { const [state, manager] = useStateManager(stateManager) const { t } = useAppTranslation() - const items = state.displayItems || [] + const [isTagPopoverOpen, setIsTagPopoverOpen] = React.useState(false) + const [tagSearch, setTagSearch] = React.useState("") + const allItems = state.displayItems || [] + const items = showInstalledOnly + ? allItems.filter((item) => state.installedMetadata.project[item.id] || state.installedMetadata.global[item.id]) + : allItems const isEmpty = items.length === 0 return ( @@ -142,7 +142,7 @@ export function MarketplaceListView({ }, }) } - className="shadow-none bg-vscode-input-background px-2"> + className="shadow-none bg-vscode-dropdown-background px-2"> {state.sortConfig.order === "asc" ? "↑" : "↓"}
@@ -176,7 +176,7 @@ export function MarketplaceListView({ )}
- + setIsTagPopoverOpen(open)}> - + e.stopPropagation()}>
e.preventDefault()}> + onMouseDown={(e) => { + e.stopPropagation() + e.preventDefault() + }}> {state.filters.tags.includes(tag) ? ( ) : ( @@ -265,7 +270,7 @@ export function MarketplaceListView({
- {state.isFetching && ( + {state.isFetching && isEmpty && (
diff --git a/webview-ui/src/components/marketplace/MarketplaceSourcesConfigView.tsx b/webview-ui/src/components/marketplace/MarketplaceSourcesConfigView.tsx index 2ef4bcbc46..88f81aa389 100644 --- a/webview-ui/src/components/marketplace/MarketplaceSourcesConfigView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceSourcesConfigView.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Checkbox } from "@/components/ui/checkbox" import { useStateManager } from "./useStateManager" -import { validateSource } from "@roo/shared/MarketplaceValidation" +import { validateSource, ValidationError } from "@roo/shared/MarketplaceValidation" import { cn } from "@src/lib/utils" export interface MarketplaceSourcesConfigProps { @@ -19,6 +19,62 @@ export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesCon const [newSourceUrl, setNewSourceUrl] = useState("") const [newSourceName, setNewSourceName] = useState("") const [error, setError] = useState("") + const [fieldErrors, setFieldErrors] = useState<{ + name?: string + url?: string + }>({}) + + // Check if name contains emoji characters + const containsEmoji = (str: string): boolean => { + // Simple emoji detection using common emoji ranges + // This avoids using Unicode property escapes which require ES2018+ + return ( + /[\ud83c\ud83d\ud83e][\ud000-\udfff]/.test(str) || // Common emoji surrogate pairs + /[\u2600-\u27BF]/.test(str) || // Misc symbols and pictographs + /[\u2300-\u23FF]/.test(str) || // Miscellaneous Technical + /[\u2700-\u27FF]/.test(str) || // Dingbats + /[\u2B50\u2B55]/.test(str) || // Star, Circle + /[\u203C\u2049\u20E3\u2122\u2139\u2194-\u2199\u21A9\u21AA]/.test(str) + ) // Punctuation + } + + // Validate input fields without submitting + const validateFields = () => { + const newErrors: { name?: string; url?: string } = {} + + // Validate name if provided + if (newSourceName) { + if (newSourceName.length > 20) { + newErrors.name = t("marketplace:sources.errors.nameTooLong") + } else if (containsEmoji(newSourceName)) { + newErrors.name = t("marketplace:sources.errors.emojiName") + } else { + // Check for duplicate names + const hasDuplicateName = state.sources.some( + (source) => source.name && source.name.toLowerCase() === newSourceName.toLowerCase(), + ) + if (hasDuplicateName) { + newErrors.name = t("marketplace:sources.errors.duplicateName") + } + } + } + + // Validate URL + if (!newSourceUrl.trim()) { + newErrors.url = t("marketplace:sources.errors.emptyUrl") + } else { + // Check for duplicate URLs + const hasDuplicateUrl = state.sources.some( + (source) => source.url.toLowerCase().trim() === newSourceUrl.toLowerCase().trim(), + ) + if (hasDuplicateUrl) { + newErrors.url = t("marketplace:sources.errors.duplicateUrl") + } + } + + setFieldErrors(newErrors) + return Object.keys(newErrors).length === 0 + } const handleAddSource = () => { const MAX_SOURCES = 10 @@ -26,11 +82,27 @@ export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesCon setError(t("marketplace:sources.errors.maxSources", { max: MAX_SOURCES })) return } + + // Clear previous errors + setError("") + + // Perform quick validation first + if (!validateFields()) { + // If we have specific field errors, show the first one as the main error + if (fieldErrors.url) { + setError(fieldErrors.url) + } else if (fieldErrors.name) { + setError(fieldErrors.name) + } + return + } + const sourceToValidate: MarketplaceSource = { - url: newSourceUrl, - name: newSourceName || undefined, + url: newSourceUrl.trim(), + name: newSourceName.trim() || undefined, enabled: true, } + const validationErrors = validateSource(sourceToValidate, state.sources) if (validationErrors.length > 0) { const errorMessages: Record = { @@ -42,7 +114,34 @@ export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesCon "name:nonvisible": "marketplace:sources.errors.nonVisibleCharsName", "name:duplicate": "marketplace:sources.errors.duplicateName", } - const error = validationErrors[0] + + // Group errors by field for better user feedback + const fieldErrorMap: Record = {} + for (const error of validationErrors) { + if (!fieldErrorMap[error.field]) { + fieldErrorMap[error.field] = [] + } + fieldErrorMap[error.field].push(error) + } + + // Update field-specific errors + const newFieldErrors: { name?: string; url?: string } = {} + if (fieldErrorMap.name) { + const error = fieldErrorMap.name[0] + const errorKey = `name:${error.message.toLowerCase().split(" ")[0]}` + newFieldErrors.name = t(errorMessages[errorKey] || error.message) + } + + if (fieldErrorMap.url) { + const error = fieldErrorMap.url[0] + const errorKey = `url:${error.message.toLowerCase().split(" ")[0]}` + newFieldErrors.url = t(errorMessages[errorKey] || error.message) + } + + setFieldErrors(newFieldErrors) + + // Set the main error message (prioritize URL errors) + const error = fieldErrorMap.url?.[0] || validationErrors[0] const errorKey = `${error.field}:${error.message.toLowerCase().split(" ")[0]}` setError(t(errorMessages[errorKey] || "marketplace:sources.errors.invalidGitUrl")) return @@ -97,16 +196,40 @@ export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesCon onChange={(e) => { setNewSourceName(e.target.value.slice(0, 20)) setError("") + setFieldErrors((prev) => ({ ...prev, name: undefined })) + + // Live validation for emojis and length + const value = e.target.value + if (value && containsEmoji(value)) { + setFieldErrors((prev) => ({ + ...prev, + name: t("marketplace:sources.errors.emojiName"), + })) + } else if (value.length >= 20) { + setFieldErrors((prev) => ({ + ...prev, + name: t("marketplace:sources.errors.nameTooLong"), + })) + } }} maxLength={20} - className="pl-10" + className={cn("pl-10", { + "border-red-500 focus-visible:ring-red-500": fieldErrors.name, + })} + onBlur={() => validateFields()} /> - + = 18 ? "text-amber-500" : "text-vscode-descriptionForeground", + newSourceName.length >= 20 ? "text-red-500" : "", + )}> {newSourceName.length}/20 + {fieldErrors.name &&

{fieldErrors.name}

}
{ setNewSourceUrl(e.target.value) setError("") + setFieldErrors((prev) => ({ ...prev, url: undefined })) + + // Live validation for empty URL + if (!e.target.value.trim()) { + setFieldErrors((prev) => ({ + ...prev, + url: t("marketplace:sources.errors.emptyUrl"), + })) + } }} - className="pl-10" + className={cn("pl-10", { + "border-red-500 focus-visible:ring-red-500": fieldErrors.url, + })} + onBlur={() => validateFields()} /> + {fieldErrors.url &&

{fieldErrors.url}

}

{t("marketplace:sources.add.urlFormats")} @@ -135,7 +271,10 @@ export function MarketplaceSourcesConfig({ stateManager }: MarketplaceSourcesCon

)} - diff --git a/webview-ui/src/components/marketplace/MarketplaceView.tsx b/webview-ui/src/components/marketplace/MarketplaceView.tsx index 7c00870c15..b864099a0e 100644 --- a/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -13,19 +13,17 @@ import { RocketConfig } from "config-rocket" import { MarketplaceSourcesConfig } from "./MarketplaceSourcesConfigView" import { MarketplaceListView } from "./MarketplaceListView" import { cn } from "@/lib/utils" -import { Package, RefreshCw, Server } from "lucide-react" +import { RefreshCw } from "lucide-react" import { TooltipProvider } from "@/components/ui/tooltip" interface MarketplaceViewProps { onDone?: () => void stateManager: MarketplaceViewStateManager } -export function MarketplaceView({ stateManager }: MarketplaceViewProps) { +export function MarketplaceView({ stateManager, onDone }: MarketplaceViewProps) { const { t } = useAppTranslation() const [state, manager] = useStateManager(stateManager) - const [tagSearch, setTagSearch] = useState("") - const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false) const [showInstallSidebar, setShowInstallSidebar] = useState< | { item: MarketplaceItem @@ -84,11 +82,7 @@ export function MarketplaceView({ stateManager }: MarketplaceViewProps) { ) // Memoize filtered tags - const filteredTags = useMemo( - () => - tagSearch ? allTags.filter((tag: string) => tag.toLowerCase().includes(tagSearch.toLowerCase())) : allTags, - [allTags, tagSearch], - ) + const filteredTags = useMemo(() => allTags, [allTags]) return ( @@ -96,53 +90,51 @@ export function MarketplaceView({ stateManager }: MarketplaceViewProps) {

{t("marketplace:title")}

- +
+ + +
+
-
-
+
+
+
+
+
@@ -153,23 +145,35 @@ export function MarketplaceView({ stateManager }: MarketplaceViewProps) {
+ +
+ +
diff --git a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts index 2fb76c68ab..1ffb77ddaa 100644 --- a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts +++ b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts @@ -21,7 +21,7 @@ export interface ViewState { allItems: MarketplaceItem[] displayItems?: MarketplaceItem[] // Items currently being displayed (filtered or all) isFetching: boolean - activeTab: "browse" | "sources" + activeTab: "browse" | "installed" | "settings" refreshingUrls: string[] sources: MarketplaceSource[] installedMetadata: FullInstallatedMetadata @@ -285,8 +285,8 @@ export class MarketplaceViewStateManager { activeTab: tab, } - // If switching to browse tab, trigger fetch - if (tab === "browse") { + // If switching to browse or installed tab, trigger fetch + if (tab === "browse" || tab === "installed") { this.state.isFetching = true vscode.postMessage({ diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx index 22f073ab25..58016e9561 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx @@ -3,6 +3,7 @@ import { MarketplaceListView } from "../MarketplaceListView" import { MarketplaceItem } from "../../../../../src/services/marketplace/types" import { ViewState } from "../MarketplaceViewStateManager" import userEvent from "@testing-library/user-event" +import { TooltipProvider } from "@/components/ui/tooltip" // Mock translation hook jest.mock("@/i18n/TranslationContext", () => ({ @@ -68,10 +69,6 @@ const defaultProps = { stateManager: {} as any, allTags: ["tag1", "tag2"], filteredTags: ["tag1", "tag2"], - tagSearch: "", - setTagSearch: jest.fn(), - isTagPopoverOpen: false, - setIsTagPopoverOpen: jest.fn(), } describe("MarketplaceListView", () => { @@ -82,29 +79,36 @@ describe("MarketplaceListView", () => { mockState.displayItems = [] }) + const renderWithProviders = (props = {}) => + render( + + + , + ) + it("renders search input", () => { - render() + renderWithProviders() const searchInput = screen.getByPlaceholderText("marketplace:filters.search.placeholder") expect(searchInput).toBeInTheDocument() }) it("renders type filter", () => { - render() + renderWithProviders() expect(screen.getByText("marketplace:filters.type.label")).toBeInTheDocument() expect(screen.getByText("marketplace:filters.type.all")).toBeInTheDocument() }) it("renders sort options", () => { - render() + renderWithProviders() expect(screen.getByText("marketplace:filters.sort.label")).toBeInTheDocument() expect(screen.getByText("marketplace:filters.sort.name")).toBeInTheDocument() }) it("renders tags section when tags are available", () => { - render() + renderWithProviders() expect(screen.getByText("marketplace:filters.tags.label")).toBeInTheDocument() expect(screen.getByText("(2)")).toBeInTheDocument() // Shows tag count @@ -113,14 +117,14 @@ describe("MarketplaceListView", () => { it("shows loading state when fetching", () => { mockState.isFetching = true - render() + renderWithProviders() expect(screen.getByText("marketplace:items.refresh.refreshing")).toBeInTheDocument() expect(screen.getByText("This may take a moment...")).toBeInTheDocument() }) it("shows empty state when no items and not fetching", () => { - render() + renderWithProviders() expect(screen.getByText("marketplace:items.empty.noItems")).toBeInTheDocument() expect(screen.getByText("Try adjusting your filters or search terms")).toBeInTheDocument() @@ -153,13 +157,13 @@ describe("MarketplaceListView", () => { ] mockState.displayItems = mockItems - render() + renderWithProviders() expect(screen.getByText("marketplace:items.count")).toBeInTheDocument() }) it("updates search filter when typing", () => { - render() + renderWithProviders() const searchInput = screen.getByPlaceholderText("marketplace:filters.search.placeholder") fireEvent.change(searchInput, { target: { value: "test" } }) @@ -174,7 +178,7 @@ describe("MarketplaceListView", () => { const user = userEvent.setup() mockState.filters.tags = ["tag1"] - render() + renderWithProviders() const clearButton = screen.getByText("marketplace:filters.tags.clear") expect(clearButton).toBeInTheDocument() diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx index fa30257ed1..8b31b5e6a4 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx @@ -1,6 +1,7 @@ -import { render, fireEvent, screen } from "@testing-library/react" +import { render, fireEvent, screen, waitFor } from "@testing-library/react" import { MarketplaceSourcesConfig } from "../MarketplaceSourcesConfigView" import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager" +import { validateSource, ValidationError } from "@roo/shared/MarketplaceValidation" // Mock the translation hook jest.mock("@/i18n/TranslationContext", () => ({ @@ -9,6 +10,11 @@ jest.mock("@/i18n/TranslationContext", () => ({ }), })) +// Mock the validateSource function +jest.mock("@roo/shared/MarketplaceValidation", () => ({ + validateSource: jest.fn(), +})) + describe("MarketplaceSourcesConfig", () => { let stateManager: MarketplaceViewStateManager @@ -20,6 +26,8 @@ describe("MarketplaceSourcesConfig", () => { payload: { sources: [] }, }) jest.clearAllMocks() + // Default mock implementation for validateSource + ;(validateSource as jest.Mock).mockReturnValue([]) }) it("shows source count", () => { @@ -68,17 +76,42 @@ describe("MarketplaceSourcesConfig", () => { }) }) - it("shows error when URL is empty", () => { + it("shows error when URL is empty on add (via client-side validation)", async () => { render() - const addButton = screen.getByText("marketplace:sources.add.button") - fireEvent.click(addButton) + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + fireEvent.change(urlInput, { target: { value: "" } }) // Set URL to empty + fireEvent.blur(urlInput) // Trigger blur to activate client-side validation + + // This error is displayed as a field-specific error message + const errorMessage = await screen.findByText("marketplace:sources.errors.emptyUrl", { + selector: "p.text-xs.text-red-500", + }) + expect(errorMessage).toBeInTheDocument() + }) + + it("shows error when URL is empty on blur", async () => { + render() + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") - const errorElement = screen.getByText("marketplace:sources.errors.invalidGitUrl") - expect(errorElement).toBeInTheDocument() + fireEvent.change(urlInput, { target: { value: "some-url" } }) + fireEvent.blur(urlInput) + await waitFor(() => { + expect( + screen.queryByText("marketplace:sources.errors.emptyUrl", { selector: "p.text-xs.text-red-500" }), + ).not.toBeInTheDocument() + }) + + fireEvent.change(urlInput, { target: { value: "" } }) + fireEvent.blur(urlInput) + await waitFor(() => { + expect( + screen.getByText("marketplace:sources.errors.emptyUrl", { selector: "p.text-xs.text-red-500" }), + ).toBeInTheDocument() + }) }) - it("shows error when max sources reached", () => { + it("shows error when max sources reached", async () => { // Add max number of sources with unique URLs const maxSources = Array(10) .fill(null) @@ -100,8 +133,10 @@ describe("MarketplaceSourcesConfig", () => { const addButton = screen.getByText("marketplace:sources.add.button") fireEvent.click(addButton) - const errorElement = screen.getByText("marketplace:sources.errors.maxSources") - expect(errorElement).toBeInTheDocument() + await waitFor(() => { + const errorMessage = screen.getByText("marketplace:sources.errors.maxSources") + expect(errorMessage).toHaveClass("text-red-500", "p-2", "bg-red-100") + }) }) it("accepts multi-part corporate git URLs", async () => { @@ -211,7 +246,7 @@ describe("MarketplaceSourcesConfig", () => { expect(screen.getByText("11/20")).toBeInTheDocument() }) - it("clears inputs after adding source", () => { + it("clears inputs after adding source", async () => { render() const nameInput = screen.getByPlaceholderText("marketplace:sources.add.namePlaceholder") @@ -224,7 +259,109 @@ describe("MarketplaceSourcesConfig", () => { const addButton = screen.getByText("marketplace:sources.add.button") fireEvent.click(addButton) - expect(nameInput).toHaveValue("") - expect(urlInput).toHaveValue("") + await waitFor(() => { + expect(nameInput).toHaveValue("") + expect(urlInput).toHaveValue("") + }) + }) + + it("shows error when name is too long on change", async () => { + render() + const nameInput = screen.getByPlaceholderText("marketplace:sources.add.namePlaceholder") + fireEvent.change(nameInput, { target: { value: "This name is way too long for the input field" } }) + await waitFor(() => { + expect( + screen.getByText("marketplace:sources.errors.nameTooLong", { selector: "p.text-xs.text-red-500" }), + ).toBeInTheDocument() + }) + }) + + it("shows error when name contains emoji on change", async () => { + render() + const nameInput = screen.getByPlaceholderText("marketplace:sources.add.namePlaceholder") + fireEvent.change(nameInput, { target: { value: "Name with emoji 🚀" } }) + await waitFor(() => { + expect( + screen.getByText("marketplace:sources.errors.emojiName", { selector: "p.text-xs.text-red-500" }), + ).toBeInTheDocument() + }) + }) + + it("shows error when URL is invalid after validation", async () => { + ;(validateSource as jest.Mock).mockReturnValue([{ field: "url", message: "invalid" } as ValidationError]) + render() + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + fireEvent.change(urlInput, { target: { value: "invalid-url" } }) + const addButton = screen.getByText("marketplace:sources.add.button") + fireEvent.click(addButton) + await waitFor(() => { + const errorMessages = screen.queryAllByText("marketplace:sources.errors.invalidGitUrl") + const fieldErrorMessage = errorMessages.find((el) => el.classList.contains("text-xs")) + expect(fieldErrorMessage).toBeInTheDocument() + }) + }) + + it("shows error when URL is a duplicate after validation", async () => { + stateManager.transition({ + type: "UPDATE_SOURCES", + payload: { sources: [{ url: "https://github.com/existing/repo", enabled: true }] }, + }) + ;(validateSource as jest.Mock).mockReturnValue([{ field: "url", message: "duplicate" } as ValidationError]) + render() + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + fireEvent.change(urlInput, { target: { value: "https://github.com/existing/repo" } }) + const addButton = screen.getByText("marketplace:sources.add.button") + fireEvent.click(addButton) + await waitFor(() => { + const errorMessages = screen.queryAllByText("marketplace:sources.errors.duplicateUrl") + const fieldErrorMessage = errorMessages.find((el) => el.classList.contains("text-xs")) + expect(fieldErrorMessage).toBeInTheDocument() + }) + }) + + it("shows error when name is a duplicate after validation", async () => { + stateManager.transition({ + type: "UPDATE_SOURCES", + payload: { sources: [{ name: "Existing Name", url: "https://github.com/existing/repo", enabled: true }] }, + }) + ;(validateSource as jest.Mock).mockReturnValue([{ field: "name", message: "duplicate" } as ValidationError]) + render() + const nameInput = screen.getByPlaceholderText("marketplace:sources.add.namePlaceholder") + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + fireEvent.change(nameInput, { target: { value: "Existing Name" } }) + fireEvent.change(urlInput, { target: { value: "https://github.com/new/repo" } }) + const addButton = screen.getByText("marketplace:sources.add.button") + fireEvent.click(addButton) + await waitFor(() => { + const errorMessages = screen.queryAllByText("marketplace:sources.errors.duplicateName") + const fieldErrorMessage = errorMessages.find((el) => el.classList.contains("text-xs")) + expect(fieldErrorMessage).toBeInTheDocument() + }) + }) + + it("disables add button when name has error", async () => { + render() + const nameInput = screen.getByPlaceholderText("marketplace:sources.add.namePlaceholder") + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + const addButton = screen.getByText("marketplace:sources.add.button") + + fireEvent.change(nameInput, { target: { value: "This name is way too long for the input field" } }) + fireEvent.change(urlInput, { target: { value: "https://valid.com/repo" } }) + + await waitFor(() => { + expect(addButton).toBeDisabled() + }) + }) + + it("disables add button when URL is empty", async () => { + render() + const urlInput = screen.getByPlaceholderText("marketplace:sources.add.urlPlaceholder") + const addButton = screen.getByText("marketplace:sources.add.button") + + fireEvent.change(urlInput, { target: { value: "" } }) + + await waitFor(() => { + expect(addButton).toBeDisabled() + }) }) }) diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceView.test.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceView.test.tsx new file mode 100644 index 0000000000..b700c74b4d --- /dev/null +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceView.test.tsx @@ -0,0 +1,480 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { MarketplaceView } from "../MarketplaceView" +import { MarketplaceItem } from "../../../../../src/services/marketplace/types" +import { ViewState } from "../MarketplaceViewStateManager" +import userEvent from "@testing-library/user-event" +import { TooltipProvider } from "@/components/ui/tooltip" +import { RocketConfig } from "config-rocket" +import { ExtensionStateContext } from "@/context/ExtensionStateContext" + +// Mock vscode API - IMPORTANT: This mock must be at the very top of the file +const mockPostMessage = jest.fn() +jest.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: mockPostMessage, + getState: jest.fn(() => ({})), // Mock getState as well if it's used + setState: jest.fn(), + }, +})) + +// Mock translation hook +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, // Return the key as-is for easy testing + }), +})) + +// Mock useEvent from react-use +let mockUseEventHandler: ((event: MessageEvent) => void) | undefined // Declare outside mock +jest.mock("react-use", () => ({ + useEvent: jest.fn((eventName, handler) => { + if (eventName === "message") { + mockUseEventHandler = handler + } + }), +})) + +// Mock ResizeObserver +class MockResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +global.ResizeObserver = MockResizeObserver + +const mockStateManager = { + state: {} as ViewState, + transition: jest.fn(), +} + +jest.mock("../useStateManager", () => ({ + useStateManager: jest.fn(() => [mockStateManager.state, { transition: mockStateManager.transition }]), +})) + +jest.mock("lucide-react", () => { + return new Proxy( + {}, + { + get: function (obj, prop) { + if (prop === "__esModule") { + return true + } + return ({ className, ...rest }: any) => ( +
+ {String(prop)} +
+ ) + }, + }, + ) +}) + +const defaultProps = { + stateManager: {} as any, // Mocked by useStateManager + onDone: jest.fn(), +} + +describe("MarketplaceView", () => { + beforeEach(() => { + jest.clearAllMocks() + mockStateManager.state = { + allItems: [], + displayItems: [], + isFetching: false, + activeTab: "browse", + refreshingUrls: [], + sources: [], + installedMetadata: { + project: {}, + global: {}, + }, + filters: { + type: "", + search: "", + tags: [], + }, + sortConfig: { + by: "name", + order: "asc", + }, + } + mockStateManager.transition.mockClear() + mockStateManager.transition.mockImplementation((action: any) => { + if (action.type === "FETCH_ITEMS") { + mockStateManager.state = { ...mockStateManager.state, isFetching: true } + } else if (action.type === "SET_ACTIVE_TAB") { + mockStateManager.state = { ...mockStateManager.state, activeTab: action.payload.tab } + } else if (action.type === "UPDATE_FILTERS") { + mockStateManager.state = { + ...mockStateManager.state, + filters: { ...mockStateManager.state.filters, ...action.payload.filters }, + } + } + }) + + window.removeEventListener("message", expect.any(Function)) + mockUseEventHandler = undefined // Reset the event handler mock + }) + + const renderWithProviders = (props = {}) => + render( + + + + + , + ) + + it("renders title and action buttons", () => { + renderWithProviders() + + expect(screen.getByText("marketplace:title")).toBeInTheDocument() + expect(screen.getByText("marketplace:refresh")).toBeInTheDocument() + expect(screen.getByText("marketplace:done")).toBeInTheDocument() + }) + + it("calls onDone when Done button is clicked", async () => { + const user = userEvent.setup() + const onDoneMock = jest.fn() + renderWithProviders({ onDone: onDoneMock }) + + await user.click(screen.getByText("marketplace:done")) + expect(onDoneMock).toHaveBeenCalledTimes(1) + }) + + it("calls FETCH_ITEMS when Refresh button is clicked", async () => { + const user = userEvent.setup() + renderWithProviders() + + await user.click(screen.getByText("marketplace:refresh")) + expect(mockStateManager.transition).toHaveBeenCalledWith({ type: "FETCH_ITEMS" }) + }) + + it("displays spinning icon when fetching", async () => { + mockStateManager.state.isFetching = true + renderWithProviders() + + await waitFor(() => { + expect(screen.getByTestId("RefreshCw-icon")).toHaveClass("animate-spin") + }) + }) + + it("switches tabs when tab buttons are clicked", async () => { + const user = userEvent.setup() + renderWithProviders() + + // Click Installed tab + await user.click(screen.getByText("marketplace:tabs.installed")) + expect(mockStateManager.transition).toHaveBeenCalledWith({ + type: "SET_ACTIVE_TAB", + payload: { tab: "installed" }, + }) + + // Click Settings tab + await user.click(screen.getByText("marketplace:tabs.settings")) + expect(mockStateManager.transition).toHaveBeenCalledWith({ + type: "SET_ACTIVE_TAB", + payload: { tab: "settings" }, + }) + + // Click Browse tab + await user.click(screen.getByText("marketplace:tabs.browse")) + expect(mockStateManager.transition).toHaveBeenCalledWith({ + type: "SET_ACTIVE_TAB", + payload: { tab: "browse" }, + }) + }) + + it("sends installMarketplaceItemWithParameters message on handleInstallSubmit", () => { + renderWithProviders() + + const mockItem: MarketplaceItem = { + id: "test-item", + repoUrl: "test-url", + name: "Test Item", + type: "mode", + description: "A test item", + url: "https://example.com", + version: "1.0.0", + author: "Test Author", + lastUpdated: "2023-01-01", + } + const mockConfig: RocketConfig = { + parameters: [], + } + + // Simulate opening the sidebar and then submitting + fireEvent( + window, + new MessageEvent("message", { + data: { + type: "openMarketplaceInstallSidebarWithConfig", + payload: { item: mockItem, config: mockConfig }, + }, + }), + ) + + // The InstallSidebar component is mocked, so we can't directly interact with its submit. + // Instead, we'll directly call the handleInstallSubmit function that would be passed to it. + // This requires a slight adjustment to how we test, or a more elaborate mock for InstallSidebar. + // For now, let's test the effect of the message event. + // The actual submission logic is within handleInstallSubmit, which is passed to InstallSidebar. + // We need to ensure that when InstallSidebar calls onSubmit, it triggers the postMessage. + + // To properly test handleInstallSubmit, we need to mock InstallSidebar and its onSubmit prop. + // For now, let's focus on the message handling and the initial fetch effects. + // A more complete test would involve mocking InstallSidebar and triggering its onSubmit. + }) + + it("opens install sidebar on 'openMarketplaceInstallSidebarWithConfig' message", async () => { + renderWithProviders() + + const mockItem: MarketplaceItem = { + id: "test-item", + repoUrl: "test-url", + name: "Test Item", + type: "mode", + description: "A test item", + url: "https://example.com", + version: "1.0.0", + author: "Test Author", + lastUpdated: "2023-01-01", + } + const mockConfig: RocketConfig = { + parameters: [], + } + + // Trigger the message event manually via the mocked handler + if (mockUseEventHandler) { + mockUseEventHandler( + new MessageEvent("message", { + data: { + type: "openMarketplaceInstallSidebarWithConfig", + payload: { item: mockItem, config: mockConfig }, + }, + }), + ) + } else { + throw new Error("mockUseEventHandler was not set!") + } + + await waitFor(() => { + expect(screen.getByTestId("install-sidebar")).toBeInTheDocument() // Use data-testid from the mock + }) + }) + + it("fetches items on initial mount if allItems is empty and not fetching", () => { + renderWithProviders() + expect(mockStateManager.transition).toHaveBeenCalledWith({ type: "FETCH_ITEMS" }) + }) + + it("does not fetch items on initial mount if allItems is not empty", () => { + mockStateManager.state.allItems = [ + { + id: "1", + name: "test", + repoUrl: "url", + type: "mode", + description: "desc", + url: "url", + version: "1.0.0", + author: "author", + lastUpdated: "date", + }, + ] + renderWithProviders() + expect(mockStateManager.transition).not.toHaveBeenCalledWith({ type: "FETCH_ITEMS" }) + }) + + it("fetches items when webview becomes visible and on browse tab", async () => { + mockStateManager.state.activeTab = "browse" + mockStateManager.state.isFetching = false + renderWithProviders() + + // Clear initial call from useEffect + mockStateManager.transition.mockClear() + + fireEvent(window, new MessageEvent("message", { data: { type: "webviewVisible", visible: true } })) + + expect(mockStateManager.transition).toHaveBeenCalledWith({ type: "FETCH_ITEMS" }) + }) + + it("does not fetch items when webview becomes visible but not on browse tab", () => { + mockStateManager.state.activeTab = "installed" + mockStateManager.state.isFetching = false + renderWithProviders() + + // Clear initial call from useEffect + mockStateManager.transition.mockClear() + + fireEvent(window, new MessageEvent("message", { data: { type: "webviewVisible", visible: true } })) + + expect(mockStateManager.transition).not.toHaveBeenCalledWith({ type: "FETCH_ITEMS" }) + }) + + it("does not fetch items when webview becomes visible but is already fetching", () => { + mockStateManager.state.activeTab = "browse" + mockStateManager.state.isFetching = true + renderWithProviders() + + // Clear initial call from useEffect + mockStateManager.transition.mockClear() + + fireEvent(window, new MessageEvent("message", { data: { type: "webviewVisible", visible: true } })) + + expect(mockStateManager.transition).not.toHaveBeenCalledWith({ type: "FETCH_ITEMS" }) + }) +}) + +// Mock InstallSidebar and MarketplaceSourcesConfig for simpler testing of MarketplaceView +jest.mock("../InstallSidebar", () => ({ + __esModule: true, + default: function MockInstallSidebar({ onSubmit, onClose, item }: any) { + return ( +
+ InstallSidebar + + +
+ ) + }, +})) + +jest.mock("../MarketplaceSourcesConfigView", () => ({ + __esModule: true, + MarketplaceSourcesConfig: function MockMarketplaceSourcesConfig() { + return
MarketplaceSourcesConfig
+ }, +})) + +// Mock MarketplaceListView +jest.mock("../MarketplaceListView", () => ({ + __esModule: true, + MarketplaceListView: function MockMarketplaceListView({ showInstalledOnly = false }: any) { + return ( +
+ MarketplaceListView + {showInstalledOnly && (Installed Only)} +
+ ) + }, +})) diff --git a/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx index 64970581c1..55f2ecb665 100644 --- a/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx +++ b/webview-ui/src/components/marketplace/components/MarketplaceItemActionsMenu.tsx @@ -9,7 +9,6 @@ import { } from "../../../../../src/services/marketplace/types" import { vscode } from "@/utils/vscode" import { useAppTranslation } from "@/i18n/TranslationContext" -import { isValidUrl } from "@roo/utils/url" import { ItemInstalledMetadata } from "@roo/services/marketplace/InstalledMetadataManager" interface MarketplaceItemActionsMenuProps { @@ -18,16 +17,17 @@ interface MarketplaceItemActionsMenuProps { project: ItemInstalledMetadata | undefined global: ItemInstalledMetadata | undefined } + triggerNode?: React.ReactNode } -export const MarketplaceItemActionsMenu: React.FC = ({ item, installed }) => { +export const MarketplaceItemActionsMenu: React.FC = ({ + item, + installed, + triggerNode, +}) => { const { t } = useAppTranslation() const itemSourceUrl = useMemo(() => { - if (item.sourceUrl && isValidUrl(item.sourceUrl)) { - return item.sourceUrl - } - let url = item.repoUrl if (item.defaultBranch) { url = `${url}/tree/${item.defaultBranch}` @@ -36,8 +36,9 @@ export const MarketplaceItemActionsMenu: React.FC { vscode.postMessage({ @@ -65,43 +66,32 @@ export const MarketplaceItemActionsMenu: React.FC - + {triggerNode ?? ( + + )} - + {/* View Source / External Link Item */} - + {t("marketplace:items.card.viewSource")} - {/* Remove (Project) */} - {installed.project ? ( - handleRemove({ target: "project" })}> - - {t("marketplace:items.card.removeProject")} - - ) : ( - handleInstall({ target: "project" })}> - - {t("marketplace:items.card.installProject")} - - )} - {/* Remove (Global) */} {installed.global ? ( handleRemove({ target: "global" })}> - + {t("marketplace:items.card.removeGlobal")} ) : ( handleInstall({ target: "global" })}> - + {t("marketplace:items.card.installGlobal")} )} diff --git a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx index fdcad95a6c..890266c205 100644 --- a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx +++ b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx @@ -12,7 +12,7 @@ import { ItemInstalledMetadata } from "@roo/services/marketplace/InstalledMetada import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { Rocket, Server, Package, Sparkles, Download } from "lucide-react" +import { Rocket, Server, Package, Sparkles, ChevronDown } from "lucide-react" interface MarketplaceItemCardProps { item: MarketplaceItem @@ -27,10 +27,10 @@ interface MarketplaceItemCardProps { } const icons = { - mode: , - mcp: , - package: , - prompt: , + mode: , + mcp: , + package: , + prompt: , } export const MarketplaceItemCard: React.FC = ({ @@ -65,63 +65,34 @@ export const MarketplaceItemCard: React.FC = ({ return (
-
- {installed.project && ( - - - - - - This package is installed in your current project workspace - - )} - {installed.global && ( - - - - - - This package is installed globally on your system - - )} -
-
+
+ + + + {icons[item.type]} + + + {typeLabel} +

{item.name}

- +
- - {icons[item.type]} {typeLabel} -

{item.description}

{item.tags && item.tags.length > 0 && ( -
+
{item.tags.map((tag) => (
- +
+ + + + + + {installed.project + ? t("marketplace:items.card.removeProject") + : t("marketplace:items.card.installProject")} + + + + + + } + /> +
{item.type === "package" && ( @@ -185,9 +195,10 @@ export const MarketplaceItemCard: React.FC = ({ interface AuthorInfoProps { item: MarketplaceItem + typeLabel: string } -const AuthorInfo: React.FC = ({ item }) => { +const AuthorInfo: React.FC = ({ item, typeLabel }) => { const { t } = useAppTranslation() const handleOpenAuthorUrl = () => { @@ -199,6 +210,7 @@ const AuthorInfo: React.FC = ({ item }) => { if (item.author) { return (

+ {typeLabel}{" "} {item.authorUrl && isValidUrl(item.authorUrl) ? ( -

@@ -145,8 +153,8 @@ export function MarketplaceView({ stateManager, onDone }: MarketplaceViewProps)
diff --git a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts index 0e4e51b1d5..c32e10265f 100644 --- a/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts +++ b/webview-ui/src/components/marketplace/MarketplaceViewStateManager.ts @@ -260,15 +260,17 @@ export class MarketplaceViewStateManager { } case "FETCH_ERROR": { - // Preserve current filters and sources - const { filters, sources, activeTab } = this.state + // Preserve current filters, sources, and items + const { filters, sources, activeTab, allItems, displayItems } = this.state - // Reset state but preserve filters and sources + // Reset state but preserve filters, sources, and items this.state = { ...this.getDefaultState(), filters, sources, activeTab, + allItems, + displayItems, isFetching: false, } this.notifyStateChange() diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx index 58016e9561..8e96975f94 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceListView.test.tsx @@ -5,14 +5,12 @@ import { ViewState } from "../MarketplaceViewStateManager" import userEvent from "@testing-library/user-event" import { TooltipProvider } from "@/components/ui/tooltip" -// Mock translation hook jest.mock("@/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ - t: (key: string) => key, // Return the key as-is for easy testing + t: (key: string) => key, }), })) -// Mock ResizeObserver class MockResizeObserver { observe() {} unobserve() {} @@ -21,7 +19,6 @@ class MockResizeObserver { global.ResizeObserver = MockResizeObserver -// Mock state manager with initial state const mockTransition = jest.fn() const mockState: ViewState = { allItems: [], @@ -45,12 +42,10 @@ const mockState: ViewState = { }, } -// Mock useStateManager hook jest.mock("../useStateManager", () => ({ useStateManager: () => [mockState, { transition: mockTransition }], })) -// Mock all lucide-react icons jest.mock("lucide-react", () => { return new Proxy( {}, diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx index 8b31b5e6a4..9dfa8bf482 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceSourcesConfig.test.tsx @@ -3,14 +3,12 @@ import { MarketplaceSourcesConfig } from "../MarketplaceSourcesConfigView" import { MarketplaceViewStateManager } from "../MarketplaceViewStateManager" import { validateSource, ValidationError } from "@roo/shared/MarketplaceValidation" -// Mock the translation hook jest.mock("@/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ - t: (key: string) => key, // Return the key as-is for testing + t: (key: string) => key, }), })) -// Mock the validateSource function jest.mock("@roo/shared/MarketplaceValidation", () => ({ validateSource: jest.fn(), })) @@ -20,13 +18,11 @@ describe("MarketplaceSourcesConfig", () => { beforeEach(() => { stateManager = new MarketplaceViewStateManager() - // Reset state manager to have no sources stateManager.transition({ type: "UPDATE_SOURCES", payload: { sources: [] }, }) jest.clearAllMocks() - // Default mock implementation for validateSource ;(validateSource as jest.Mock).mockReturnValue([]) }) @@ -83,7 +79,6 @@ describe("MarketplaceSourcesConfig", () => { fireEvent.change(urlInput, { target: { value: "" } }) // Set URL to empty fireEvent.blur(urlInput) // Trigger blur to activate client-side validation - // This error is displayed as a field-specific error message const errorMessage = await screen.findByText("marketplace:sources.errors.emptyUrl", { selector: "p.text-xs.text-red-500", }) @@ -112,7 +107,6 @@ describe("MarketplaceSourcesConfig", () => { }) it("shows error when max sources reached", async () => { - // Add max number of sources with unique URLs const maxSources = Array(10) .fill(null) .map((_, i) => ({ @@ -233,7 +227,6 @@ describe("MarketplaceSourcesConfig", () => { const longName = "This is a very long source name that exceeds limit" fireEvent.change(nameInput, { target: { value: longName } }) - // The component should truncate to 20 chars expect(nameInput).toHaveValue(longName.slice(0, 20)) }) diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceView.test.tsx b/webview-ui/src/components/marketplace/__tests__/MarketplaceView.test.tsx index ddda448509..4e39f2aea7 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceView.test.tsx +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceView.test.tsx @@ -7,25 +7,22 @@ import { TooltipProvider } from "@/components/ui/tooltip" import type { RocketConfig } from "config-rocket" import { ExtensionStateContext } from "@/context/ExtensionStateContext" -// Mock vscode API - IMPORTANT: This mock must be at the very top of the file const mockPostMessage = jest.fn() jest.mock("@src/utils/vscode", () => ({ vscode: { postMessage: mockPostMessage, - getState: jest.fn(() => ({})), // Mock getState as well if it's used + getState: jest.fn(() => ({})), setState: jest.fn(), }, })) -// Mock translation hook jest.mock("@/i18n/TranslationContext", () => ({ useAppTranslation: () => ({ - t: (key: string) => key, // Return the key as-is for easy testing + t: (key: string) => key, }), })) -// Mock useEvent from react-use -let mockUseEventHandler: ((event: MessageEvent) => void) | undefined // Declare outside mock +let mockUseEventHandler: ((event: MessageEvent) => void) | undefined jest.mock("react-use", () => ({ useEvent: jest.fn((eventName, handler) => { if (eventName === "message") { @@ -34,7 +31,6 @@ jest.mock("react-use", () => ({ }), })) -// Mock ResizeObserver class MockResizeObserver { observe() {} unobserve() {} @@ -237,6 +233,7 @@ describe("MarketplaceView", () => { experiments: { autoCondenseContext: false, powerSteering: false, + marketplace: true, }, marketplaceSources: [], }}> @@ -254,7 +251,7 @@ describe("MarketplaceView", () => { expect(screen.getByText("marketplace:done")).toBeInTheDocument() }) - it("calls onDone when Done button is clicked", async () => { + it("calls onDone when Done button is clicked and active tab is browse or installed", async () => { const user = userEvent.setup() const onDoneMock = jest.fn() renderWithProviders({ onDone: onDoneMock }) diff --git a/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts b/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts index 1d752778e0..4dda0ca7c4 100644 --- a/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts +++ b/webview-ui/src/components/marketplace/__tests__/MarketplaceViewStateManager.test.ts @@ -46,7 +46,7 @@ describe("MarketplaceViewStateManager", () => { }) describe("Initial State", () => { - it.skip("should initialize with default state", () => { + it("should initialize with default state", () => { const state = manager.getState() expect(state).toEqual({ allItems: [], @@ -55,6 +55,10 @@ describe("MarketplaceViewStateManager", () => { activeTab: "browse", refreshingUrls: [], sources: [DEFAULT_MARKETPLACE_SOURCE], + installedMetadata: { + project: {}, + global: {}, + }, filters: { type: "", search: "", @@ -103,13 +107,12 @@ describe("MarketplaceViewStateManager", () => { }) describe("Fetch Transitions", () => { - it.skip("should handle FETCH_ITEMS transition", async () => { + it("should handle FETCH_ITEMS transition", async () => { jest.clearAllMocks() // Clear mock to ignore initialize() call await manager.transition({ type: "FETCH_ITEMS" }) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "fetchMarketplaceItems", - bool: true, }) const state = manager.getState() @@ -398,11 +401,12 @@ describe("MarketplaceViewStateManager", () => { }) describe("Error Handling", () => { - it.skip("should handle fetch timeout", async () => { + it("should handle fetch timeout", async () => { await manager.transition({ type: "FETCH_ITEMS" }) - // Fast-forward past the timeout + // Fast-forward past the timeout and simulate error message jest.advanceTimersByTime(30000) + manager.handleMessage({ type: "marketplaceButtonClicked", text: "error" }) const state = manager.getState() expect(state.isFetching).toBe(false) @@ -583,7 +587,7 @@ describe("MarketplaceViewStateManager", () => { expect(state.isFetching).toBe(false) }) - it.skip("should handle marketplace button click for refresh", () => { + it("should handle marketplace button click for refresh", () => { manager.handleMessage({ type: "marketplaceButtonClicked", }) @@ -592,7 +596,6 @@ describe("MarketplaceViewStateManager", () => { expect(state.isFetching).toBe(true) expect(vscode.postMessage).toHaveBeenCalledWith({ type: "fetchMarketplaceItems", - bool: true, }) }) }) @@ -608,7 +611,7 @@ describe("MarketplaceViewStateManager", () => { expect(state.activeTab).toBe("settings") }) - it.skip("should trigger initial fetch when switching to browse with no items", async () => { + it("should trigger initial fetch when switching to browse with no items", async () => { jest.clearAllMocks() // Clear mock to ignore initialize() call // Start in settings tab @@ -625,7 +628,6 @@ describe("MarketplaceViewStateManager", () => { expect(vscode.postMessage).toHaveBeenCalledWith({ type: "fetchMarketplaceItems", - bool: true, }) }) @@ -656,7 +658,7 @@ describe("MarketplaceViewStateManager", () => { }) }) - it.skip("should automatically fetch when sources are modified and viewing browse tab", async () => { + it("should automatically fetch when sources are modified and viewing browse tab", async () => { jest.clearAllMocks() // Clear mock to ignore initialize() call // Add some items first @@ -680,7 +682,6 @@ describe("MarketplaceViewStateManager", () => { // Should trigger fetch due to source modification expect(vscode.postMessage).toHaveBeenCalledWith({ type: "fetchMarketplaceItems", - bool: true, }) }) @@ -698,11 +699,12 @@ describe("MarketplaceViewStateManager", () => { }) describe("Fetch Timeout Handling", () => { - it.skip("should handle fetch timeout", async () => { + it("should handle fetch timeout", async () => { await manager.transition({ type: "FETCH_ITEMS" }) - // Fast-forward past the timeout + // Fast-forward past the timeout and simulate error message jest.advanceTimersByTime(30000) + manager.handleMessage({ type: "marketplaceButtonClicked", text: "error" }) const state = manager.getState() expect(state.isFetching).toBe(false) @@ -756,7 +758,7 @@ describe("MarketplaceViewStateManager", () => { expect(state.activeTab).toBe("settings") }) - it.skip("should make minimal state updates when timeout occurs in browse tab", async () => { + it("should make minimal state updates when timeout occurs in browse tab", async () => { // First ensure we're in browse tab await manager.transition({ type: "SET_ACTIVE_TAB", @@ -770,26 +772,24 @@ describe("MarketplaceViewStateManager", () => { payload: { items: testItems }, }) - // Start a new fetch - await manager.transition({ type: "FETCH_ITEMS" }) - // Track state changes let stateChangeCount = 0 const unsubscribe = manager.onStateChange(() => { stateChangeCount++ }) - // Reset the counter since we've already had state changes - stateChangeCount = 0 + // Start a new fetch + await manager.transition({ type: "FETCH_ITEMS" }) - // Fast-forward past the timeout + // Fast-forward past the timeout and simulate error message jest.advanceTimersByTime(30000) + manager.handleMessage({ type: "marketplaceButtonClicked", text: "error" }) // Clean up the handler unsubscribe() - // Verify we got a state update - expect(stateChangeCount).toBe(1) + // Verify we got a state update (one for FETCH_ITEMS, one for FETCH_ERROR) + expect(stateChangeCount).toBe(2) // Verify the items were preserved const state = manager.getState() @@ -825,7 +825,7 @@ describe("MarketplaceViewStateManager", () => { jest.useRealTimers() }) - it.skip("should trigger fetch for remaining source after source deletion when in browse tab", async () => { + it("should trigger fetch for remaining source after source deletion when in browse tab", async () => { // Start with two sources const sources = [ { url: "https://github.com/test/repo1", enabled: true }, @@ -855,7 +855,6 @@ describe("MarketplaceViewStateManager", () => { // Verify that a fetch was triggered for the remaining source expect(vscode.postMessage).toHaveBeenCalledWith({ type: "fetchMarketplaceItems", - bool: true, }) // Verify state has the remaining source diff --git a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx index b854b99b30..0c1a46627a 100644 --- a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx +++ b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { Rocket, Server, Package, Sparkles, ChevronDown } from "lucide-react" +import { useExtensionState } from "@/context/ExtensionStateContext" interface MarketplaceItemCardProps { item: MarketplaceItem @@ -42,6 +43,7 @@ export const MarketplaceItemCard: React.FC = ({ setActiveTab, }) => { const { t } = useAppTranslation() + const { cwd } = useExtensionState() const typeLabel = useMemo(() => { const labels: Partial> = { @@ -137,28 +139,35 @@ export const MarketplaceItemCard: React.FC = ({
- + + + - {installed.project - ? t("marketplace:items.card.removeProject") - : t("marketplace:items.card.installProject")} + {!cwd + ? t("marketplace:items.card.noWorkspaceTooltip") + : installed.project + ? t("marketplace:items.card.removeProject") + : t("marketplace:items.card.installProject")} = ({ triggerNode={ diff --git a/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx index 707d2ad037..a316c90815 100644 --- a/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx +++ b/webview-ui/src/components/marketplace/components/__tests__/MarketplaceItemCard.test.tsx @@ -310,6 +310,25 @@ describe("MarketplaceItemCard", () => { }) }) + it("disables install button and shows tooltip when no workspace is open", async () => { + // Mock useExtensionState to simulate no workspace + // eslint-disable-next-line @typescript-eslint/no-require-imports + jest.spyOn(require("@/context/ExtensionStateContext"), "useExtensionState").mockReturnValue({ + filePaths: [], + } as any) + + const user = userEvent.setup() + renderWithProviders() + + const installButton = screen.getByRole("button", { name: "Install Project" }) + expect(installButton).toBeDisabled() + + // Hover to trigger tooltip + await user.hover(installButton) + const tooltip = await screen.findByText("Open a workspace to install marketplace items") + expect(tooltip).toBeInTheDocument() + }) + describe("MarketplaceItemCard expandable section badge", () => { it("shows badge count for matched sub-items", () => { const packageItem: MarketplaceItem = { diff --git a/webview-ui/src/i18n/locales/ca/marketplace.json b/webview-ui/src/i18n/locales/ca/marketplace.json index cbb91167ff..773207374c 100644 --- a/webview-ui/src/i18n/locales/ca/marketplace.json +++ b/webview-ui/src/i18n/locales/ca/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Eliminar", "removeGlobal": "Eliminar (Global)", "viewSource": "Veure", - "viewOnSource": "Veure a {{source}}" + "viewOnSource": "Veure a {{source}}", + "noWorkspaceTooltip": "Obre un espai de treball per instal·lar elements del marketplace" } }, "installProjectTooltip": "Instal·lació del projecte", diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 2a90eda13d..0391b08774 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -469,6 +469,11 @@ "placeholder": "Enter your custom condensing prompt here...\n\nYou can use the same structure as the default prompt:\n- Previous Conversation\n- Current Work\n- Key Technical Concepts\n- Relevant Files and Code\n- Problem Solving\n- Pending Tasks and Next Steps", "reset": "Reset to Default", "hint": "Empty = use default prompt" + }, + "MARKETPLACE": { + "name": "Habilitar Marketplace a Roo Code", + "description": "Quan està habilitat, Roo podrà instal·lar i gestionar elements del Marketplace.", + "warning": "El Marketplace encara no està habilitat. Si voleu ser un dels primers a adoptar-lo, activeu-lo a la configuració experimental." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/marketplace.json b/webview-ui/src/i18n/locales/de/marketplace.json index 52a4d4b06a..cde4c0ef4e 100644 --- a/webview-ui/src/i18n/locales/de/marketplace.json +++ b/webview-ui/src/i18n/locales/de/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Entfernen", "removeGlobal": "Entfernen (Global)", "viewSource": "Ansehen", - "viewOnSource": "Auf {{source}} ansehen" + "viewOnSource": "Auf {{source}} ansehen", + "noWorkspaceTooltip": "Öffne einen Arbeitsbereich, um Marketplace-Elemente zu installieren" } }, "installProjectTooltip": "Projektinstallation", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 6543f989c4..2da5fd35fa 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Experimentelles Multi-Block-Diff-Werkzeug verwenden", "description": "Wenn aktiviert, verwendet Roo das Multi-Block-Diff-Werkzeug. Dies versucht, mehrere Codeblöcke in der Datei in einer Anfrage zu aktualisieren." + }, + "MARKETPLACE": { + "name": "Marktplatz in Roo Code aktivieren", + "description": "Wenn aktiviert, kann Roo Elemente vom Marktplatz installieren und verwalten.", + "warning": "Der Marktplatz ist noch nicht aktiviert. Wenn du ein Early Adopter sein möchtest, aktiviere ihn bitte in den Experimentellen Einstellungen." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/marketplace.json b/webview-ui/src/i18n/locales/en/marketplace.json index cc384e8419..8e3335e7b9 100644 --- a/webview-ui/src/i18n/locales/en/marketplace.json +++ b/webview-ui/src/i18n/locales/en/marketplace.json @@ -66,7 +66,8 @@ "removeProject": "Remove", "removeGlobal": "Remove (Global)", "viewSource": "View", - "viewOnSource": "View on {{source}}" + "viewOnSource": "View on {{source}}", + "noWorkspaceTooltip": "Open a workspace to install marketplace items" } }, "sources": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index d77e2b3081..2c12474e15 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Use experimental multi block diff tool", "description": "When enabled, Roo will use multi block diff tool. This will try to update multiple code blocks in the file in one request." + }, + "MARKETPLACE": { + "name": "Enable Marketplace in Roo Code", + "description": "When enabled, Roo will be able to install and manage items from the Marketplace.", + "warning": "The Marketplace is not yet enabled. If you want to be an early adopter, please enable it in the Experimental Settings." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/marketplace.json b/webview-ui/src/i18n/locales/es/marketplace.json index 5de3ec4458..57bb065de5 100644 --- a/webview-ui/src/i18n/locales/es/marketplace.json +++ b/webview-ui/src/i18n/locales/es/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Eliminar", "removeGlobal": "Eliminar (Global)", "viewSource": "Ver", - "viewOnSource": "Ver en {{source}}" + "viewOnSource": "Ver en {{source}}", + "noWorkspaceTooltip": "Abre un espacio de trabajo para instalar elementos del marketplace" } }, "installProjectTooltip": "Instalación del proyecto", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 90601ba0d4..999493beea 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Usar herramienta experimental de diff de bloques múltiples", "description": "Cuando está habilitado, Roo usará la herramienta de diff de bloques múltiples. Esto intentará actualizar múltiples bloques de código en el archivo en una sola solicitud." + }, + "MARKETPLACE": { + "name": "Habilitar Marketplace en Roo Code", + "description": "Cuando está habilitado, Roo podrá instalar y administrar elementos del Marketplace.", + "warning": "El Marketplace aún no está habilitado. Si desea ser un adoptador temprano, habilítelo en la Configuración experimental." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/marketplace.json b/webview-ui/src/i18n/locales/fr/marketplace.json index 3e09ccfd74..563695c793 100644 --- a/webview-ui/src/i18n/locales/fr/marketplace.json +++ b/webview-ui/src/i18n/locales/fr/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Supprimer", "removeGlobal": "Supprimer (Global)", "viewSource": "Voir", - "viewOnSource": "Voir sur {{source}}" + "viewOnSource": "Voir sur {{source}}", + "noWorkspaceTooltip": "Ouvrez un espace de travail pour installer les éléments du marketplace" } }, "installProjectTooltip": "Installation du projet", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index f3ae43f05d..3f52ee9310 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Utiliser l'outil diff multi-blocs expérimental", "description": "Lorsqu'il est activé, Roo utilisera l'outil diff multi-blocs. Cela tentera de mettre à jour plusieurs blocs de code dans le fichier en une seule requête." + }, + "MARKETPLACE": { + "name": "Activer le Marketplace dans Roo Code", + "description": "Lorsqu'il est activé, Roo pourra installer et gérer des éléments du Marketplace.", + "warning": "Le Marketplace n'est pas encore activé. Si vous souhaitez être un adopteur précoce, veuillez l'activer dans les paramètres expérimentaux." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/marketplace.json b/webview-ui/src/i18n/locales/hi/marketplace.json index 5f9d95004c..2ff4041106 100644 --- a/webview-ui/src/i18n/locales/hi/marketplace.json +++ b/webview-ui/src/i18n/locales/hi/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "हटाएं", "removeGlobal": "हटाएं (ग्लोबल)", "viewSource": "देखें", - "viewOnSource": "{{source}} पर देखें" + "viewOnSource": "{{source}} पर देखें", + "noWorkspaceTooltip": "मार्केटप्लेस आइटम इंस्टॉल करने के लिए एक कार्यक्षेत्र खोलें" } }, "installProjectTooltip": "परियोजना स्थापना", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index fc02f508f6..9afd5bf370 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "प्रायोगिक मल्टी ब्लॉक diff उपकरण का उपयोग करें", "description": "जब सक्षम किया जाता है, तो Roo मल्टी ब्लॉक diff उपकरण का उपयोग करेगा। यह एक अनुरोध में फ़ाइल में कई कोड ब्लॉक अपडेट करने का प्रयास करेगा।" + }, + "MARKETPLACE": { + "name": "Roo Code में मार्केटप्लेस सक्षम करें", + "description": "जब सक्षम होता है, तो Roo मार्केटप्लेस से आइटम स्थापित और प्रबंधित करने में सक्षम होगा।", + "warning": "मार्केटप्लेस अभी तक सक्षम नहीं है। यदि आप शुरुआती अपनाने वाले बनना चाहते हैं, तो कृपया इसे प्रायोगिक सेटिंग्स में सक्षम करें।" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/marketplace.json b/webview-ui/src/i18n/locales/it/marketplace.json index 46e46d0401..9fbbce09e1 100644 --- a/webview-ui/src/i18n/locales/it/marketplace.json +++ b/webview-ui/src/i18n/locales/it/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Rimuovi", "removeGlobal": "Rimuovi (Globale)", "viewSource": "Visualizza", - "viewOnSource": "Visualizza su {{source}}" + "viewOnSource": "Visualizza su {{source}}", + "noWorkspaceTooltip": "Apri un'area di lavoro per installare gli elementi del marketplace" } }, "installProjectTooltip": "Installazione del progetto", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index cca396698f..5eb11a11eb 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Usa strumento diff multi-blocco sperimentale", "description": "Quando abilitato, Roo utilizzerà lo strumento diff multi-blocco. Questo tenterà di aggiornare più blocchi di codice nel file in una singola richiesta." + }, + "MARKETPLACE": { + "name": "Abilita Marketplace in Roo Code", + "description": "Quando abilitato, Roo sarà in grado di installare e gestire elementi dal Marketplace.", + "warning": "Il Marketplace non è ancora abilitato. Se vuoi essere un early adopter, abilitalo nelle Impostazioni sperimentali." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/marketplace.json b/webview-ui/src/i18n/locales/ja/marketplace.json index 61deb6a88b..57ab7c0927 100644 --- a/webview-ui/src/i18n/locales/ja/marketplace.json +++ b/webview-ui/src/i18n/locales/ja/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "削除", "removeGlobal": "削除 (グローバル)", "viewSource": "表示", - "viewOnSource": "{{source}}で表示" + "viewOnSource": "{{source}}で表示", + "noWorkspaceTooltip": "マーケットプレイスアイテムをインストールするには、ワークスペースを開いてください" } }, "installProjectTooltip": "プロジェクトのインストール", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index c960751ae8..92e7fdf95d 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "実験的なマルチブロックdiffツールを使用する", "description": "有効にすると、Rooはマルチブロックdiffツールを使用します。これにより、1つのリクエストでファイル内の複数のコードブロックを更新しようとします。" + }, + "MARKETPLACE": { + "name": "Roo Codeでマーケットプレイスを有効にする", + "description": "有効にすると、Rooはマーケットプレイスからアイテムをインストールおよび管理できるようになります。", + "warning": "マーケットプレイスはまだ有効になっていません。早期導入者になりたい場合は、実験的設定で有効にしてください。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/marketplace.json b/webview-ui/src/i18n/locales/ko/marketplace.json index 4d227bf404..55d7ff6956 100644 --- a/webview-ui/src/i18n/locales/ko/marketplace.json +++ b/webview-ui/src/i18n/locales/ko/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "제거", "removeGlobal": "제거 (글로벌)", "viewSource": "보기", - "viewOnSource": "{{source}}에서 보기" + "viewOnSource": "{{source}}에서 보기", + "noWorkspaceTooltip": "마켓플레이스 항목을 설치하려면 작업 영역을 여세요" } }, "installProjectTooltip": "프로젝트 설치", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index aff0025e66..8b572a4b7a 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "실험적 다중 블록 diff 도구 사용", "description": "활성화하면 Roo가 다중 블록 diff 도구를 사용합니다. 이것은 하나의 요청에서 파일의 여러 코드 블록을 업데이트하려고 시도합니다." + }, + "MARKETPLACE": { + "name": "Roo Code에서 마켓플레이스 활성화", + "description": "활성화하면 Roo는 마켓플레이스에서 항목을 설치하고 관리할 수 있습니다.", + "warning": "마켓플레이스는 아직 활성화되지 않았습니다. 얼리 어답터가 되려면 실험적 설정에서 활성화하세요." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/marketplace.json b/webview-ui/src/i18n/locales/nl/marketplace.json index 56c026a659..4718dcc60d 100644 --- a/webview-ui/src/i18n/locales/nl/marketplace.json +++ b/webview-ui/src/i18n/locales/nl/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Verwijderen", "removeGlobal": "Verwijderen (Globaal)", "viewSource": "Bekijken", - "viewOnSource": "Bekijken op {{source}}" + "viewOnSource": "Bekijken op {{source}}", + "noWorkspaceTooltip": "Open een werkruimte om marketplace-items te installeren" } }, "installProjectTooltip": "Projectinstallatie", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 0d1393ab51..ac17de6767 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Experimentele multi-block diff-tool gebruiken", "description": "Indien ingeschakeld, gebruikt Roo de multi-block diff-tool. Hiermee wordt geprobeerd meerdere codeblokken in het bestand in één verzoek bij te werken." + }, + "MARKETPLACE": { + "name": "Marktplaats in Roo Code inschakelen", + "description": "Indien ingeschakeld, kan Roo items van de Marktplaats installeren en beheren.", + "warning": "De Marktplaats is nog niet ingeschakeld. Als je een vroege gebruiker wilt zijn, schakel deze dan in de Experimentele instellingen in." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/marketplace.json b/webview-ui/src/i18n/locales/pl/marketplace.json index 29526ad5b0..72620d8071 100644 --- a/webview-ui/src/i18n/locales/pl/marketplace.json +++ b/webview-ui/src/i18n/locales/pl/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Usuń", "removeGlobal": "Usuń (Globalny)", "viewSource": "Zobacz", - "viewOnSource": "Zobacz na {{source}}" + "viewOnSource": "Zobacz na {{source}}", + "noWorkspaceTooltip": "Otwórz obszar roboczy, aby zainstalować elementy Marketplace" } }, "installProjectTooltip": "Instalacja projektu", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index ba7d7e8b31..dbd2e40869 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Użyj eksperymentalnego narzędzia diff wieloblokowego", "description": "Po włączeniu, Roo użyje narzędzia diff wieloblokowego. Spróbuje to zaktualizować wiele bloków kodu w pliku w jednym żądaniu." + }, + "MARKETPLACE": { + "name": "Włącz Marketplace w Roo Code", + "description": "Po włączeniu, Roo będzie w stanie instalować i zarządzać elementami z Marketplace.", + "warning": "Marketplace nie jest jeszcze włączony. Jeśli chcesz być wczesnym użytkownikiem, włącz go w Ustawieniach eksperymentalnych." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/marketplace.json b/webview-ui/src/i18n/locales/pt-BR/marketplace.json index 5fd6580f48..bea167259d 100644 --- a/webview-ui/src/i18n/locales/pt-BR/marketplace.json +++ b/webview-ui/src/i18n/locales/pt-BR/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Remover", "removeGlobal": "Remover (Global)", "viewSource": "Ver", - "viewOnSource": "Ver no {{source}}" + "viewOnSource": "Ver no {{source}}", + "noWorkspaceTooltip": "Abra um espaço de trabalho para instalar itens do marketplace" } }, "installProjectTooltip": "Instalação do projeto", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index c4df622806..99b2ef321c 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -30,7 +30,7 @@ "terminal": "Terminal", "experimental": "Experimental", "language": "Idioma", - "about": "Sobre" + "about": "Sobre Roo Code" }, "codeIndex": { "title": "Indexação de Código", @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Usar ferramenta diff de múltiplos blocos experimental", "description": "Quando ativado, o Roo usará a ferramenta diff de múltiplos blocos. Isso tentará atualizar vários blocos de código no arquivo em uma única solicitação." + }, + "MARKETPLACE": { + "name": "Ativar Marketplace no Roo Code", + "description": "Quando ativado, o Roo poderá instalar e gerenciar itens do Marketplace.", + "warning": "O Marketplace ainda não está ativado. Se você quiser ser um adotante inicial, ative-o nas Configurações experimentais." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/marketplace.json b/webview-ui/src/i18n/locales/ru/marketplace.json index 5a9559363e..f2f9955ece 100644 --- a/webview-ui/src/i18n/locales/ru/marketplace.json +++ b/webview-ui/src/i18n/locales/ru/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Удалить", "removeGlobal": "Удалить (Глобально)", "viewSource": "Просмотреть", - "viewOnSource": "Просмотреть на {{source}}" + "viewOnSource": "Просмотреть на {{source}}", + "noWorkspaceTooltip": "Откройте рабочую область для установки элементов marketplace" } }, "installProjectTooltip": "Установка проекта", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index eb4e11bf12..4c2b6d81fe 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Использовать экспериментальный мультиблочный инструмент диффа", "description": "Если включено, Roo будет использовать мультиблочный инструмент диффа, пытаясь обновить несколько блоков кода за один запрос." + }, + "MARKETPLACE": { + "name": "Включить Marketplace в Roo Code", + "description": "Если включено, Roo сможет устанавливать элементы из Marketplace и управлять ими.", + "warning": "Marketplace ещё не включён. Если вы хотите стать ранним пользователем, включите его в экспериментальных настройках." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/marketplace.json b/webview-ui/src/i18n/locales/tr/marketplace.json index 2fbdfd7ab5..d40af6b478 100644 --- a/webview-ui/src/i18n/locales/tr/marketplace.json +++ b/webview-ui/src/i18n/locales/tr/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Kaldır", "removeGlobal": "Kaldır (Global)", "viewSource": "Görüntüle", - "viewOnSource": "{{source}} üzerinde görüntüle" + "viewOnSource": "{{source}} üzerinde görüntüle", + "noWorkspaceTooltip": "Marketplace öğelerini yüklemek için bir çalışma alanı açın" } }, "installProjectTooltip": "Proje kurulumu", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 766d2b7aa9..ed22f5837f 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Deneysel çoklu blok diff aracını kullan", "description": "Etkinleştirildiğinde, Roo çoklu blok diff aracını kullanacaktır. Bu, tek bir istekte dosyadaki birden fazla kod bloğunu güncellemeye çalışacaktır." + }, + "MARKETPLACE": { + "name": "Roo Code'da Pazaryeri'ni etkinleştir", + "description": "Etkinleştirildiğinde, Roo Pazaryeri'nden öğeleri yükleyebilir ve yönetebilir.", + "warning": "Pazaryeri henüz etkinleştirilmedi. Erken benimseyen olmak istiyorsanız, lütfen Deneysel Ayarlar'da etkinleştirin." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/marketplace.json b/webview-ui/src/i18n/locales/vi/marketplace.json index 2293812b1b..53ec3f3fff 100644 --- a/webview-ui/src/i18n/locales/vi/marketplace.json +++ b/webview-ui/src/i18n/locales/vi/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "Gỡ cài đặt", "removeGlobal": "Gỡ cài đặt (Toàn cục)", "viewSource": "Xem", - "viewOnSource": "Xem trên {{source}}" + "viewOnSource": "Xem trên {{source}}", + "noWorkspaceTooltip": "Mở một không gian làm việc để cài đặt các mục marketplace" } }, "installProjectTooltip": "Cài đặt dự án", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index a64574c9c1..00c66e5b37 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -30,7 +30,7 @@ "terminal": "Terminal", "experimental": "Thử nghiệm", "language": "Ngôn ngữ", - "about": "Giới thiệu" + "about": "Giới thiệu Roo Code" }, "codeIndex": { "title": "Lập chỉ mục mã nguồn", @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "Sử dụng công cụ diff đa khối thử nghiệm", "description": "Khi được bật, Roo sẽ sử dụng công cụ diff đa khối. Điều này sẽ cố gắng cập nhật nhiều khối mã trong tệp trong một yêu cầu." + }, + "MARKETPLACE": { + "name": "Bật Marketplace trong Roo Code", + "description": "Khi được bật, Roo sẽ có thể cài đặt và quản lý các mục từ Marketplace.", + "warning": "Marketplace chưa được bật. Nếu bạn muốn trở thành người dùng sớm, vui lòng bật nó trong Cài đặt thử nghiệm." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/marketplace.json b/webview-ui/src/i18n/locales/zh-CN/marketplace.json index ad5914d63d..53e7e683b0 100644 --- a/webview-ui/src/i18n/locales/zh-CN/marketplace.json +++ b/webview-ui/src/i18n/locales/zh-CN/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "移除(项目)", "removeGlobal": "移除(全局)", "viewSource": "查看", - "viewOnSource": "在 {{source}} 上查看" + "viewOnSource": "在 {{source}} 上查看", + "noWorkspaceTooltip": "打开工作区以安装市场项目" } }, "installProjectTooltip": "项目安装", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 6416b9e824..8d03a5aa73 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -451,24 +451,29 @@ "description": "智能上下文压缩使用 LLM 调用来总结过去的对话,在任务上下文窗口达到预设阈值时进行,而不是在上下文填满时丢弃旧消息。" }, "DIFF_STRATEGY_UNIFIED": { - "name": "启用diff更新工具", - "description": "可减少因模型错误导致的重复尝试,但可能引发意外操作。启用前请确保理解风险并会仔细检查所有修改。" + "name": "使用实验性统一差异更新策略", + "description": "启用实验性统一差异更新策略。此策略可能会减少因模型错误导致的重试次数,但可能导致意外行为或不正确的编辑。仅在您理解风险并愿意仔细审查所有更改时才启用。" }, "SEARCH_AND_REPLACE": { - "name": "启用搜索和替换工具", + "name": "使用实验性搜索和替换工具", "description": "启用实验性搜索和替换工具,允许 Roo 在一个请求中替换搜索词的多个实例。" }, "INSERT_BLOCK": { - "name": "启用插入内容工具", - "description": "允许 Roo 在特定行号插入内容,无需处理差异。" + "name": "使用实验性插入内容工具", + "description": "启用实验性插入内容工具,允许 Roo 在特定行号插入内容,无需创建差异。" }, "POWER_STEERING": { - "name": "启用增强导向模式", - "description": "开启后,Roo 将更频繁地向模型推送当前模式定义的详细信息,从而强化对角色设定和自定义指令的遵循力度。注意:此模式会提升每条消息的 token 消耗量。" + "name": "使用实验性“增强导向”模式", + "description": "启用后,Roo 将更频繁地提醒模型其当前模式定义的详细信息。这将导致更严格地遵守角色定义和自定义指令,但每条消息将使用更多 Token。" }, "MULTI_SEARCH_AND_REPLACE": { - "name": "允许批量搜索和替换", - "description": "启用后,Roo 将尝试在一个请求中进行批量搜索和替换。" + "name": "使用实验性多块差异工具", + "description": "启用后,Roo 将使用多块差异工具。这将尝试在一个请求中更新文件中的多个代码块。" + }, + "MARKETPLACE": { + "name": "在 Roo Code 中启用应用商店", + "description": "启用后,Roo 将能够从应用商店安装和管理项目。", + "warning": "应用商店尚未启用。如果您想成为早期采用者,请在实验性设置中启用它。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/marketplace.json b/webview-ui/src/i18n/locales/zh-TW/marketplace.json index cb1150b252..8d5d590415 100644 --- a/webview-ui/src/i18n/locales/zh-TW/marketplace.json +++ b/webview-ui/src/i18n/locales/zh-TW/marketplace.json @@ -65,7 +65,8 @@ "removeProject": "移除", "removeGlobal": "移除 (全域)", "viewSource": "檢視", - "viewOnSource": "在 {{source}} 上檢視" + "viewOnSource": "在 {{source}} 上檢視", + "noWorkspaceTooltip": "開啟工作區以安裝市集項目" } }, "installProjectTooltip": "專案安裝", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 9c682569fc..94f897bff4 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -469,6 +469,11 @@ "MULTI_SEARCH_AND_REPLACE": { "name": "使用實驗性多區塊差異比對工具", "description": "啟用後,Roo 將使用多區塊差異比對工具,嘗試在單一請求中更新檔案內的多個程式碼區塊。" + }, + "MARKETPLACE": { + "name": "在 Roo Code 中啟用 Marketplace", + "description": "啟用後,Roo 將能夠從 Marketplace 安裝和管理項目。", + "warning": "Marketplace 尚未啟用。如果您想成為早期採用者,請在實驗性設定中啟用它。" } }, "promptCaching": { From 2d51d5ea5672723ae83bd27b93dabdca5d71f3eb Mon Sep 17 00:00:00 2001 From: Dicha Zelianivan Arkana <51877647+elianiva@users.noreply.github.com> Date: Sun, 25 May 2025 20:26:09 +0700 Subject: [PATCH 19/23] style(marketplace): refactor filter match ui, more accessible install button placement (#15) * refactor(marketplace): subtle 'match' style * chore: add translations * refactor(marketplace): move install ui button * test(marketplace): update outdated tests --- .../components/marketplace/InstallSidebar.tsx | 2 +- .../components/ExpandableSection.tsx | 10 +- .../components/MarketplaceItemCard.tsx | 8 +- .../marketplace/components/TypeGroup.tsx | 61 +++------ .../__tests__/ExpandableSection.test.tsx | 7 +- .../__tests__/MarketplaceItemCard.test.tsx | 124 +++++++++++++++++- .../src/i18n/locales/ca/marketplace.json | 15 +-- .../src/i18n/locales/de/marketplace.json | 45 ++++--- .../src/i18n/locales/en/marketplace.json | 2 +- .../src/i18n/locales/es/marketplace.json | 49 ++++--- .../src/i18n/locales/fr/marketplace.json | 49 ++++--- .../src/i18n/locales/hi/marketplace.json | 49 ++++--- .../src/i18n/locales/it/marketplace.json | 47 ++++--- .../src/i18n/locales/ja/marketplace.json | 49 ++++--- .../src/i18n/locales/ko/marketplace.json | 49 ++++--- .../src/i18n/locales/nl/marketplace.json | 47 ++++--- .../src/i18n/locales/pl/marketplace.json | 49 ++++--- .../src/i18n/locales/pt-BR/marketplace.json | 41 +++--- .../src/i18n/locales/ru/marketplace.json | 41 +++--- .../src/i18n/locales/tr/marketplace.json | 43 +++--- .../src/i18n/locales/vi/marketplace.json | 41 +++--- .../src/i18n/locales/zh-CN/marketplace.json | 43 +++--- .../src/i18n/locales/zh-TW/marketplace.json | 41 +++--- 23 files changed, 499 insertions(+), 413 deletions(-) diff --git a/webview-ui/src/components/marketplace/InstallSidebar.tsx b/webview-ui/src/components/marketplace/InstallSidebar.tsx index 7875ad5c63..1be9c76147 100644 --- a/webview-ui/src/components/marketplace/InstallSidebar.tsx +++ b/webview-ui/src/components/marketplace/InstallSidebar.tsx @@ -41,7 +41,7 @@ const InstallSidebar: React.FC = ({ item, config className="flex flex-col p-4 bg-vscode-sideBar-background text-vscode-foreground h-full w-3/4 shadow-lg" // Adjust width and add shadow onClick={(e) => e.stopPropagation()}>

Install {item.name}

-
+
{config.parameters?.map((param) => { // Only render prompt parameters if (param.resolver.operation !== "prompt") return null diff --git a/webview-ui/src/components/marketplace/components/ExpandableSection.tsx b/webview-ui/src/components/marketplace/components/ExpandableSection.tsx index d9e85c736f..1c9c6d67fd 100644 --- a/webview-ui/src/components/marketplace/components/ExpandableSection.tsx +++ b/webview-ui/src/components/marketplace/components/ExpandableSection.tsx @@ -8,6 +8,7 @@ interface ExpandableSectionProps { className?: string defaultExpanded?: boolean badge?: string + matched?: boolean } export const ExpandableSection: React.FC = ({ @@ -16,6 +17,7 @@ export const ExpandableSection: React.FC = ({ className, defaultExpanded = false, badge, + matched = false, }) => { // Create a unique value for the accordion item const accordionValue = React.useMemo(() => `section-${title.replace(/\s+/g, "-").toLowerCase()}`, [title]) @@ -25,19 +27,21 @@ export const ExpandableSection: React.FC = ({ type="single" collapsible defaultValue={defaultExpanded ? accordionValue : undefined} - className={cn("border-t-0", className)}> + className={cn("border-t-0", className, { + "bg-vscode-list-activeSelectionBackground": matched, + })}> -
+
{title} {badge && ( - + {badge} )} diff --git a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx index 0c1a46627a..5482dc17c1 100644 --- a/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx +++ b/webview-ui/src/components/marketplace/components/MarketplaceItemCard.tsx @@ -62,7 +62,7 @@ export const MarketplaceItemCard: React.FC = ({ const expandableSectionBadge = useMemo(() => { const matchCount = item.items?.filter((subItem) => subItem.matchInfo?.matched).length ?? 0 - return matchCount > 0 ? t("marketplace:items.components", { count: matchCount }) : undefined + return matchCount > 0 ? t("marketplace:items.matched", { count: matchCount }) : undefined }, [item.items, t]) return ( @@ -93,8 +93,10 @@ export const MarketplaceItemCard: React.FC = ({