diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d9717718..e3ed0a5b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -70,7 +70,9 @@ "mcp__serena__think_about_collected_information", "mcp__serena__think_about_task_adherence", "mcp__serena__think_about_whether_you_are_done", - "mcp__serena__write_memory" + "mcp__serena__write_memory", + "Bash(npx vitest *)", + "Bash(pnpm --filter extension tsc --noEmit)" ], "deny": [] } diff --git a/.serena/project.yml b/.serena/project.yml index b40c0729..f0fbf17e 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,10 +1,9 @@ -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 +# whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true -# list of additional paths to ignore -# same syntax as gitignore, so you can use * and ** -# Was previously called `ignored_dirs`, please update your config if you are using that. -# Added (renamed)on 2025-04-07 + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] # whether the project is in read-only mode @@ -12,45 +11,9 @@ ignored_paths: [] # Added on 2025-04-18 read_only: false -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project @@ -67,18 +30,24 @@ project_name: "selection-command" # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # the encoding used by text files in the project @@ -86,22 +55,33 @@ fixed_tools: [] encoding: utf-8 # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig -# powershell python python_jedi r rego -# ruby ruby_solargraph rust scala swift -# terraform toml typescript typescript_vts vue -# yaml zig -# +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. languages: -- typescript + - typescript # time budget (seconds) per tool call for the retrieval of additional symbol information # such as docstrings or parameter information. @@ -138,3 +118,8 @@ ignored_memory_patterns: [] # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. # No documentation on options means no options are available. ls_specific_settings: {} + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index 8d88d6d1..9f7c9a61 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -1247,17 +1247,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "Kann auf dieser Seite nicht ausgeführt werden (Seiten-URL stimmt nicht überein)" - }, - "commandHub_add_success": { - "message": "Befehl hinzugefügt!" - }, - "commandHub_add_error": { - "message": "Fehler beim Hinzufügen des Befehls." - }, - "commandHub_delete_success": { - "message": "Befehl gelöscht." - }, - "commandHub_delete_error": { - "message": "Fehler beim Löschen des Befehls." } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 2245b928..b6a0352f 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -1253,17 +1253,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "Cannot run on this page (page URL does not match)" - }, - "commandHub_add_success": { - "message": "Command added!" - }, - "commandHub_add_error": { - "message": "Failed to add command." - }, - "commandHub_delete_success": { - "message": "Command deleted." - }, - "commandHub_delete_error": { - "message": "Failed to delete command." } } diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index 40fa7b2c..7aadf686 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -1247,17 +1247,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "No se puede ejecutar en esta página (la URL de la página no coincide)" - }, - "commandHub_add_success": { - "message": "¡Comando añadido!" - }, - "commandHub_add_error": { - "message": "Error al añadir el comando." - }, - "commandHub_delete_success": { - "message": "Comando eliminado." - }, - "commandHub_delete_error": { - "message": "Error al eliminar el comando." } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 8cb7f21c..ca4c32f1 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -1247,17 +1247,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "Impossible d'exécuter sur cette page (l'URL de la page ne correspond pas)" - }, - "commandHub_add_success": { - "message": "Commande ajoutée !" - }, - "commandHub_add_error": { - "message": "Échec de l'ajout de la commande." - }, - "commandHub_delete_success": { - "message": "Commande supprimée." - }, - "commandHub_delete_error": { - "message": "Échec de la suppression de la commande." } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index c12baec9..abd92477 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -1247,17 +1247,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "इस पेज पर नहीं चला सकते (पृष्ठ URL मेल नहीं खाता)" - }, - "commandHub_add_success": { - "message": "कमांड जोड़ा गया!" - }, - "commandHub_add_error": { - "message": "कमांड जोड़ने में विफल।" - }, - "commandHub_delete_success": { - "message": "कमांड हटाया गया।" - }, - "commandHub_delete_error": { - "message": "कमांड हटाने में विफल।" } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 0fc81d1c..effb3a26 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -1250,17 +1250,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "Tidak dapat dijalankan di halaman ini (URL Halaman tidak cocok)" - }, - "commandHub_add_success": { - "message": "Perintah ditambahkan!" - }, - "commandHub_add_error": { - "message": "Gagal menambahkan perintah." - }, - "commandHub_delete_success": { - "message": "Perintah dihapus." - }, - "commandHub_delete_error": { - "message": "Gagal menghapus perintah." } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index 4a81f221..ca533b2a 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -1247,17 +1247,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "Impossibile eseguire su questa pagina (l'URL della pagina non corrisponde)" - }, - "commandHub_add_success": { - "message": "Comando aggiunto!" - }, - "commandHub_add_error": { - "message": "Impossibile aggiungere il comando." - }, - "commandHub_delete_success": { - "message": "Comando eliminato." - }, - "commandHub_delete_error": { - "message": "Impossibile eliminare il comando." } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index 0a85400d..3ac84d7e 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -1244,17 +1244,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "このページでは実行できません(ページURLが一致しません)" - }, - "commandHub_add_success": { - "message": "コマンドを追加しました!" - }, - "commandHub_add_error": { - "message": "コマンドの追加に失敗しました。" - }, - "commandHub_delete_success": { - "message": "コマンドを削除しました。" - }, - "commandHub_delete_error": { - "message": "コマンドの削除に失敗しました。" } } diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index ff0d2e5d..75c61c9f 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -1247,17 +1247,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "이 페이지에서 실행할 수 없습니다 (페이지 URL이 일치하지 않습니다)" - }, - "commandHub_add_success": { - "message": "명령이 추가되었습니다!" - }, - "commandHub_add_error": { - "message": "명령 추가에 실패했습니다." - }, - "commandHub_delete_success": { - "message": "명령이 삭제되었습니다." - }, - "commandHub_delete_error": { - "message": "명령 삭제에 실패했습니다." } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index 831d3d13..dcaefb80 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -1250,17 +1250,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "Tidak boleh dijalankan pada halaman ini (URL Halaman tidak sepadan)" - }, - "commandHub_add_success": { - "message": "Arahan ditambahkan!" - }, - "commandHub_add_error": { - "message": "Gagal menambah arahan." - }, - "commandHub_delete_success": { - "message": "Arahan dipadam." - }, - "commandHub_delete_error": { - "message": "Gagal memadam arahan." } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index e5767ffc..63f2af3b 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -1250,17 +1250,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "Não é possível executar nesta página (URL da página não corresponde)" - }, - "commandHub_add_success": { - "message": "Comando adicionado!" - }, - "commandHub_add_error": { - "message": "Falha ao adicionar o comando." - }, - "commandHub_delete_success": { - "message": "Comando excluído." - }, - "commandHub_delete_error": { - "message": "Falha ao excluir o comando." } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index a02df32b..419f1a3e 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -1250,17 +1250,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "Não é possível executar nesta página (URL da página não corresponde)" - }, - "commandHub_add_success": { - "message": "Comando adicionado!" - }, - "commandHub_add_error": { - "message": "Falha ao adicionar o comando." - }, - "commandHub_delete_success": { - "message": "Comando eliminado." - }, - "commandHub_delete_error": { - "message": "Falha ao eliminar o comando." } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index caac6a56..31c98caa 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -1247,17 +1247,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "Невозможно выполнить на этой странице (URL страницы не совпадает)" - }, - "commandHub_add_success": { - "message": "Команда добавлена!" - }, - "commandHub_add_error": { - "message": "Не удалось добавить команду." - }, - "commandHub_delete_success": { - "message": "Команда удалена." - }, - "commandHub_delete_error": { - "message": "Не удалось удалить команду." } -} \ No newline at end of file +} diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 829d0086..8adb75f2 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -1247,17 +1247,5 @@ }, "Menu_disabled_urlNotMatch": { "message": "无法在此页面运行(页面URL不匹配)" - }, - "commandHub_add_success": { - "message": "命令已添加!" - }, - "commandHub_add_error": { - "message": "添加命令失败。" - }, - "commandHub_delete_success": { - "message": "命令已删除。" - }, - "commandHub_delete_error": { - "message": "删除命令失败。" } -} \ No newline at end of file +} diff --git a/packages/extension/src/components/commandHub/CommandHub.tsx b/packages/extension/src/components/commandHub/CommandHub.tsx index 0fcbbd2b..b730d975 100644 --- a/packages/extension/src/components/commandHub/CommandHub.tsx +++ b/packages/extension/src/components/commandHub/CommandHub.tsx @@ -1,11 +1,6 @@ -import { DownloadButton } from "@/components/commandHub/DownloadButton" -import { StarButton } from "@/components/commandHub/StarButton" +import { useCommandHubBridge } from "@/hooks/useCommandHubBridge" export const CommandHub = (): JSX.Element => { - return ( - <> - - - - ) + useCommandHubBridge() + return <> } diff --git a/packages/extension/src/components/commandHub/DownloadButton.tsx b/packages/extension/src/components/commandHub/DownloadButton.tsx deleted file mode 100644 index f9561ac2..00000000 --- a/packages/extension/src/components/commandHub/DownloadButton.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { useState, useEffect } from "react" -import clsx from "clsx" -import { toast, Toaster } from "sonner" -import { Ipc, BgCommand } from "@/services/ipc" -import { useSection } from "@/hooks/useSettings" -import { useDetectUrlChanged } from "@/hooks/useDetectUrlChanged" -import { CACHE_SECTIONS } from "@/services/settings/settingsCache" -import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" -import { - Popover, - PopoverContent, - PopoverAnchor, - PopoverArrow, -} from "@/components/ui/popover" -import { SCREEN, HUB_URL } from "@/const" -import { t } from "@/services/i18n" - -const TooltipDuration = 2000 - -export const DownloadButton = (): JSX.Element => { - const [position, setPosition] = useState(null) - const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS) - const { addUrlChangeListener, removeUrlChangeListener } = - useDetectUrlChanged() - const [shouldRender, setShouldRender] = useState(false) - const open = position != null - - const setButtonClickListener = () => { - document.querySelectorAll("button[data-command]").forEach((button) => { - if (!(button instanceof HTMLButtonElement)) return - const command = button.dataset.command - const id = button.dataset.id - if (command == null) return - button.dataset.clickable = "true" - - // Deprecated: - // We will remove this in the future. - // Please use postMessage to communicate with the content script. - button.addEventListener("click", () => { - Ipc.send(BgCommand.addCommand, { command }).then((res) => { - if (res) { - sendEvent( - ANALYTICS_EVENTS.COMMAND_HUB_ADD, - { id }, - SCREEN.COMMAND_HUB, - ) - setPosition(button.parentElement) - } - }) - }) - }) - } - - const updateButtonVisibility = () => { - const ids = commands?.map((c) => c.id) ?? [] - ids.forEach((id) => { - // hide installed buttons - const installed = document.querySelector( - `button[data-id="${id}"]`, - ) as HTMLElement - if (installed) installed.style.display = "none" - // show installed label - const p = document.querySelector(`p[data-id="${id}"]`) as HTMLElement - if (p) p.style.display = "block" - }) - } - - const updateCount = () => { - document.querySelectorAll("span[data-id]").forEach((span) => { - if (!(span instanceof HTMLElement)) return - const count = Number(span.dataset.downloadCount) - if (count == null || isNaN(count)) return - let reviced = 0 - const cmd = commands?.find((c) => c.id === span.dataset.id) - if (cmd != null) { - // There is a command. - reviced++ - } - span.textContent = (count + reviced).toLocaleString() - }) - } - - useEffect(() => { - setButtonClickListener() - updateButtonVisibility() - addUrlChangeListener(setButtonClickListener) - addUrlChangeListener(updateButtonVisibility) - return () => { - removeUrlChangeListener(setButtonClickListener) - } - }, []) - - useEffect(() => { - updateButtonVisibility() - updateCount() - addUrlChangeListener(updateButtonVisibility) - addUrlChangeListener(updateCount) - return () => { - removeUrlChangeListener(updateButtonVisibility) - removeUrlChangeListener(updateCount) - } - }, [commands]) - - useEffect(() => { - if (!open) return - const timer = setTimeout(() => setPosition(null), TooltipDuration) - return () => { - clearTimeout(timer) - } - }, [open]) - - useEffect(() => { - let timer: NodeJS.Timeout - if (open) { - timer = setTimeout(() => { - setShouldRender(true) - }, 100) - } else { - setShouldRender(false) - } - return () => clearTimeout(timer) - }, [open]) - - /** - * External postMessage API for adding/deleting commands from the Hub. - * - * This content script listens for messages from the Hub page (origin must match HUB_URL). - * The message object must have the following shape: - * - * --- AddCommand --- - * { - * action: "AddCommand", - * command: string // JSON-stringified command object (see below) - * } - * - * The `command` field is a JSON string representing a SearchCommand, an AiPromptCommand, or a PageActionCommand. - * - * SearchCommand (openMode is one of "popup" | "tab" | "window" | "backgroundTab" | "sidePanel"): - * { - * id: string, // Unique command identifier - * title: string, // Display name of the command - * searchUrl: string, // Search URL template (%s is replaced with selected text) - * iconUrl: string, // URL of the command icon - * openMode: string, // How to open the result: "popup" | "tab" | "window" | "backgroundTab" | "sidePanel" - * openModeSecondary?: string, // Secondary open mode (optional) - * spaceEncoding?: string, // Space encoding in URL: "plus" | "percent" (optional) - * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional) - * sourceId?: string // Identifier of the source (optional) - * } - * - * AiPromptCommand (openMode is "aiPrompt"): - * { - * id: string, // Unique command identifier - * title: string, // Display name of the command - * iconUrl: string, // URL of the command icon - * openMode: "aiPrompt", // Must be "aiPrompt" for AI prompt commands - * aiPromptOption: { - * serviceId: string, // ID of the AI service to use (see hub/public/data/ai-services.json) - * prompt: string, // Prompt text sent to the AI service (supports variable placeholders) - * openMode: string // How to open the AI service result: "popup" | "tab" | "window" | etc. - * }, - * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional) - * sourceId?: string // Identifier of the source (optional) - * } - * - * PageActionCommand (openMode is "pageAction"): - * { - * id: string, // Unique command identifier - * title: string, // Display name of the command - * iconUrl: string, // URL of the command icon - * openMode: "pageAction", // Must be "pageAction" for page action commands - * pageActionOption: { - * startUrl: string, // URL to open when executing the page action - * pageUrl?: string, // URL pattern for command enablement (currentTab mode only, optional) - * openMode: string, // How to open the page: "none" | "popup" | "tab" | "backgroundTab" | "window" | "currentTab" - * steps: Array, // Sequence of automation steps to execute - * userVariables?: Array<{ name: string, value: string }> // User-defined variables (optional) - * }, - * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional) - * sourceId?: string // Identifier of the source (optional) - * } - * - * --- DeleteCommand --- - * { - * action: "DeleteCommand", - * id: string // ID of the command to remove - * } - */ - useEffect(() => { - const hubOrigin = new URL(HUB_URL).origin - const handleMessage = (event: MessageEvent) => { - if (event.origin !== hubOrigin) return - const { action, command, id } = event.data ?? {} - if (action === "AddCommand") { - if (typeof command !== "string") return - Ipc.send(BgCommand.addCommand, { command }) - .then((res) => { - if (res) { - toast.success(t("commandHub_add_success")) - } else { - toast.error(t("commandHub_add_error")) - } - }) - .catch(() => { - toast.error(t("commandHub_add_error")) - }) - } else if (action === "DeleteCommand") { - if (typeof id !== "string") return - Ipc.send(BgCommand.removeCommand, { id }) - .then((res) => { - if (res) { - toast.success(t("commandHub_delete_success")) - } else { - toast.error(t("commandHub_delete_error")) - } - }) - .catch(() => { - toast.error(t("commandHub_delete_error")) - }) - } - } - window.addEventListener("message", handleMessage) - return () => { - window.removeEventListener("message", handleMessage) - } - }, []) - - return ( - <> - - - - {shouldRender && ( - - Command added! - - - )} - - - ) -} diff --git a/packages/extension/src/components/commandHub/StarButton.tsx b/packages/extension/src/components/commandHub/StarButton.tsx deleted file mode 100644 index 3f172c25..00000000 --- a/packages/extension/src/components/commandHub/StarButton.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useEffect, useCallback } from "react" -import { useSection } from "@/hooks/useSettings" -import { CACHE_SECTIONS } from "@/services/settings/settingsCache" -import { sendEvent, ANALYTICS_EVENTS } from "@/services/analytics" -import { SCREEN } from "@/const" -import { useDetectUrlChanged } from "@/hooks/useDetectUrlChanged" -import { Ipc, BgCommand } from "@/services/ipc" - -function isButtonElement(elm: Element): elm is HTMLButtonElement { - return elm.tagName?.toLowerCase() === "button" -} - -function findButtonElement(elm: Element): HTMLButtonElement | undefined { - if (elm == null) return undefined - if (elm.nodeName === "body") return undefined - if (isButtonElement(elm)) return elm - return findButtonElement(elm.parentElement as Element) -} - -export const StarButton = (): JSX.Element => { - const { data: stars } = useSection(CACHE_SECTIONS.STARS) - const { addUrlChangeListener, removeUrlChangeListener } = - useDetectUrlChanged() - - const updateStar = useCallback( - (e: MouseEvent) => { - const button = findButtonElement(e.target as Element) - const id = button?.dataset.starId - if (id == null) return - const found = stars?.some((s) => s.id === id) ?? false - sendEvent( - found - ? ANALYTICS_EVENTS.COMMAND_HUB_STAR_REMOVE - : ANALYTICS_EVENTS.COMMAND_HUB_STAR_ADD, - { id }, - SCREEN.COMMAND_HUB, - ) - Ipc.send(BgCommand.toggleStar, { id }) - }, - [stars], - ) - - const updateButton = useCallback(() => { - document.querySelectorAll("button[data-star-id]").forEach((button) => { - if (!(button instanceof HTMLButtonElement)) return - const id = button.dataset.starId - if (id == null) return - button.addEventListener("click", updateStar) - button.dataset.clickable = "true" - if (stars?.some((s) => s.id === id)) { - button.dataset.starred = "true" - } else { - button.dataset.starred = "false" - } - }) - }, [stars, updateStar]) - - const updateCount = useCallback(() => { - document.querySelectorAll("span[data-star-id]").forEach((span) => { - if (!(span instanceof HTMLElement)) return - const count = Number(span.dataset.starCount) - if (count == null || isNaN(count)) return - let reviced = 0 - const star = stars?.find((s) => s.id === span.dataset.starId) - if (star != null) { - // There is a new star. - reviced++ - } - span.textContent = (count + reviced).toLocaleString() - }) - }, [stars]) - - const removeButtonEvent = useCallback(() => { - document.querySelectorAll("button[data-star-id]").forEach((button) => { - if (!(button instanceof HTMLButtonElement)) return - button.removeEventListener("click", updateStar) - }) - }, [updateStar]) - - useEffect(() => { - updateButton() - updateCount() - addUrlChangeListener(updateButton) - addUrlChangeListener(updateCount) - return () => { - removeUrlChangeListener(updateButton) - removeUrlChangeListener(updateCount) - removeButtonEvent() - } - }, [stars]) - - return <> -} diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts new file mode 100644 index 00000000..fd06a080 --- /dev/null +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -0,0 +1,187 @@ +import { useEffect, useRef } from "react" +import { Ipc, BgCommand } from "@/services/ipc" +import { useSection } from "@/hooks/useSettings" +import { CACHE_SECTIONS } from "@/services/settings/settingsCache" +import { + sendEvent, + ANALYTICS_EVENTS, + getOrCreateClientId, +} from "@/services/analytics" +import { SCREEN, NEW_HUB_URL } from "@/const" + +const hubOrigin = new URL(NEW_HUB_URL).origin + +/** + * External postMessage API for adding/deleting commands from the Hub. + * + * This content script listens for messages from the Hub page (origin must match NEW_HUB_URL). + * The message object must have the following shape: + * + * --- AddCommand --- + * { + * action: "AddCommand", + * command: string // JSON-stringified command object (see below) + * } + * + * The `command` field is a JSON string representing a SearchCommand, an AiPromptCommand, or a PageActionCommand. + * + * SearchCommand (openMode is one of "popup" | "tab" | "window" | "backgroundTab" | "sidePanel"): + * { + * id: string, // Unique command identifier + * title: string, // Display name of the command + * searchUrl: string, // Search URL template (%s is replaced with selected text) + * iconUrl: string, // URL of the command icon + * openMode: string, // How to open the result: "popup" | "tab" | "window" | "backgroundTab" | "sidePanel" + * openModeSecondary?: string, // Secondary open mode (optional) + * spaceEncoding?: string, // Space encoding in URL: "plus" | "percent" (optional) + * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional) + * sourceId?: string // Identifier of the source (optional) + * } + * + * AiPromptCommand (openMode is "aiPrompt"): + * { + * id: string, // Unique command identifier + * title: string, // Display name of the command + * iconUrl: string, // URL of the command icon + * openMode: "aiPrompt", // Must be "aiPrompt" for AI prompt commands + * aiPromptOption: { + * serviceId: string, // ID of the AI service to use (see hub/public/data/ai-services.json) + * prompt: string, // Prompt text sent to the AI service (supports variable placeholders) + * openMode: string // How to open the AI service result: "popup" | "tab" | "window" | etc. + * }, + * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional) + * sourceId?: string // Identifier of the source (optional) + * } + * + * PageActionCommand (openMode is "pageAction"): + * { + * id: string, // Unique command identifier + * title: string, // Display name of the command + * iconUrl: string, // URL of the command icon + * openMode: "pageAction", // Must be "pageAction" for page action commands + * pageActionOption: { + * startUrl: string, // URL to open when executing the page action + * pageUrl?: string, // URL pattern for command enablement (currentTab mode only, optional) + * openMode: string, // How to open the page: "none" | "popup" | "tab" | "backgroundTab" | "window" | "currentTab" + * steps: Array, // Sequence of automation steps to execute + * userVariables?: Array<{ name: string, value: string }> // User-defined variables (optional) + * }, + * sourceType?: string, // Origin of the command: "default" | "selfCreated" | "hubCommunity" | "unknown" (optional) + * sourceId?: string // Identifier of the source (optional) + * } + * + * --- DeleteCommand --- + * { + * action: "DeleteCommand", + * id: string // ID of the command to remove + * } + * + * --- AddCommandAck (response) --- + * { + * action: "AddCommandAck", + * result: boolean, // true if the command was added successfully, false otherwise + * install_id: string // stable anonymous identifier per extension install (UUID, persisted in chrome.storage.local) + * } + * + * --- DeleteCommandAck (response) --- + * { + * action: "DeleteCommandAck", + * result: boolean // true if the command was removed successfully, false otherwise + * } + * + * --- RequestInstalledCommand (from Hub) --- + * { + * action: "RequestInstalledCommand" + * } + * + * --- SyncInstalledCommand (response / proactive push) --- + * { + * action: "SyncInstalledCommand", + * installedIds: string[] // IDs of all currently installed commands + * } + */ + +export function useCommandHubBridge() { + const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS) + + // Ref keeps the message handler (empty deps) in sync with the latest commands + // without needing to recreate the listener on every change. + const commandsRef = useRef(commands) + useEffect(() => { + commandsRef.current = commands + }, [commands]) + + // Proactively push installed IDs to the Hub whenever the commands list changes. + useEffect(() => { + if (commands == null) return + window.postMessage( + { + action: "SyncInstalledCommand", + installedIds: commands.map((c) => c.id), + }, + hubOrigin, + ) + }, [commands]) + + useEffect(() => { + const handleMessage = async (event: MessageEvent) => { + if (event.origin !== hubOrigin) return + const { action, command, id } = event.data ?? {} + if (action === "AddCommand") { + if (typeof command !== "string") return + const install_id = await getOrCreateClientId() + Ipc.send(BgCommand.addCommand, { command }) + .then(async (res) => { + ;(event.source as WindowProxy)?.postMessage( + { action: "AddCommandAck", result: !!res, install_id }, + { targetOrigin: event.origin }, + ) + if (res) { + let commandId: string | undefined + try { + commandId = JSON.parse(command).id + } catch { + // Ignore parse errors; analytics will be sent without id + } + await sendEvent( + ANALYTICS_EVENTS.COMMAND_HUB_ADD, + { id: commandId }, + SCREEN.COMMAND_HUB, + ) + } + }) + .catch(() => { + ;(event.source as WindowProxy)?.postMessage( + { action: "AddCommandAck", result: false, install_id }, + { targetOrigin: event.origin }, + ) + }) + } else if (action === "DeleteCommand") { + if (typeof id !== "string") return + Ipc.send(BgCommand.removeCommand, { id }) + .then((res) => { + ;(event.source as WindowProxy)?.postMessage( + { action: "DeleteCommandAck", result: !!res }, + { targetOrigin: event.origin }, + ) + }) + .catch(() => { + ;(event.source as WindowProxy)?.postMessage( + { action: "DeleteCommandAck", result: false }, + { targetOrigin: event.origin }, + ) + }) + } else if (action === "RequestInstalledCommand") { + const ids = commandsRef.current?.map((c) => c.id) ?? [] + ;(event.source as WindowProxy)?.postMessage( + { action: "SyncInstalledCommand", installedIds: ids }, + { targetOrigin: event.origin }, + ) + } + } + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + } + }, []) +} diff --git a/packages/extension/src/hooks/useDetectUrlChanged.ts b/packages/extension/src/hooks/useDetectUrlChanged.ts deleted file mode 100644 index 342571e6..00000000 --- a/packages/extension/src/hooks/useDetectUrlChanged.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useState, useEffect } from "react" - -type Listener = () => void -type Listeners = Listener[] - -export function useDetectUrlChanged() { - const [listener, setListener] = useState([]) - - const addUrlChangeListener = (l: Listener) => { - setListener((prev) => [...prev, l]) - } - - const removeUrlChangeListener = (l: Listener) => { - setListener((prev) => prev.filter((f) => f !== l)) - } - - useEffect(() => { - const observeUrlChange = () => { - let oldHref = document.location.href - const body = document.querySelector("body") - const observer = new MutationObserver(() => { - if (oldHref !== document.location.href) { - oldHref = document.location.href - listener.forEach((l) => l()) - } - }) - observer.observe(body as Node, { childList: true, subtree: true }) - return observer - } - const observer = observeUrlChange() - return () => observer.disconnect() - }, [listener]) - - return { addUrlChangeListener, removeUrlChangeListener } -} diff --git a/packages/extension/src/services/analytics.ts b/packages/extension/src/services/analytics.ts index 304efc25..a53eaa80 100644 --- a/packages/extension/src/services/analytics.ts +++ b/packages/extension/src/services/analytics.ts @@ -77,7 +77,7 @@ export async function sendEvent( } } -async function getOrCreateClientId() { +export async function getOrCreateClientId() { let clientId = await Storage.get(LOCAL_STORAGE_KEY.CLIENT_ID) if (!clientId) { clientId = crypto.randomUUID()