diff --git a/src/serena/agent.py b/src/serena/agent.py index 14ec2d0a..796b8c93 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -557,6 +557,15 @@ class SerenaAgent: else: self.issue_task(task, name=task_name) + def remove_language(self, language: Language) -> None: + """ + Removes a language from the active project, shutting down the respective language server and updating the project configuration. + The removal is scheduled via the agent's task executor and executed asynchronously. + + :param language: the language to remove + """ + self.issue_task(lambda: self.get_active_project_or_raise().remove_language(language), name=f"RemoveLanguage:{language.value}") + def get_tool(self, tool_class: type[TTool]) -> TTool: return self._all_tools[tool_class] # type: ignore diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index d59ff8f1..2325af90 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -50,6 +50,20 @@ class ResponseConfigOverview(BaseModel): available_modes: list[dict[str, str | bool]] available_contexts: list[dict[str, str | bool]] available_memories: list[str] | None + jetbrains_mode: bool + languages: list[str] + + +class ResponseAvailableLanguages(BaseModel): + languages: list[str] + + +class RequestAddLanguage(BaseModel): + language: str + + +class RequestRemoveLanguage(BaseModel): + language: str class SerenaDashboardAPI: @@ -127,6 +141,35 @@ class SerenaDashboardAPI: self._shutdown() return {"status": "shutting down"} + @self._app.route("/get_available_languages", methods=["GET"]) + def get_available_languages() -> dict[str, Any]: + result = self._get_available_languages() + return result.model_dump() + + @self._app.route("/add_language", methods=["POST"]) + def add_language() -> dict[str, str]: + request_data = request.get_json() + if not request_data: + return {"status": "error", "message": "No data provided"} + request_add_language = RequestAddLanguage.model_validate(request_data) + try: + self._add_language(request_add_language) + return {"status": "success", "message": f"Language {request_add_language.language} added successfully"} + except Exception as e: + return {"status": "error", "message": str(e)} + + @self._app.route("/remove_language", methods=["POST"]) + def remove_language() -> dict[str, str]: + request_data = request.get_json() + if not request_data: + return {"status": "error", "message": "No data provided"} + request_remove_language = RequestRemoveLanguage.model_validate(request_data) + try: + self._remove_language(request_remove_language) + return {"status": "success", "message": f"Language {request_remove_language.language} removed successfully"} + except Exception as e: + return {"status": "error", "message": str(e)} + def _get_log_messages(self, request_log: RequestLog) -> ResponseLog: all_messages = self._memory_log_handler.get_log_messages() requested_messages = all_messages[request_log.start_idx :] if request_log.start_idx <= len(all_messages) else [] @@ -233,6 +276,11 @@ class SerenaDashboardAPI: if self._agent.tool_is_active("read_memory") and project is not None: available_memories = project.memories_manager.list_memories() + # Get list of languages for the active project + languages = [] + if project is not None: + languages = [lang.value for lang in project.project_config.languages] + return ResponseConfigOverview( active_project=project_info, context=context_info, @@ -244,6 +292,8 @@ class SerenaDashboardAPI: available_modes=available_modes, available_contexts=available_contexts, available_memories=available_memories, + jetbrains_mode=self._agent.serena_config.jetbrains, + languages=languages, ) def _shutdown(self) -> None: @@ -255,6 +305,46 @@ class SerenaDashboardAPI: # noinspection PyUnresolvedReferences os._exit(0) + def _get_available_languages(self) -> ResponseAvailableLanguages: + from solidlsp.ls_config import Language + + # Get all non-experimental languages + all_languages = [lang.value for lang in Language.iter_all(include_experimental=False)] + + # Filter out already added languages for the active project + project = self._agent.get_active_project() + if project: + current_languages = [lang.value for lang in project.project_config.languages] + available_languages = [lang for lang in all_languages if lang not in current_languages] + else: + available_languages = all_languages + + return ResponseAvailableLanguages(languages=sorted(available_languages)) + + def _add_language(self, request_add_language: RequestAddLanguage) -> None: + from solidlsp.ls_config import Language + + # Convert string to Language enum + try: + language = Language(request_add_language.language) + except ValueError: + raise ValueError(f"Invalid language: {request_add_language.language}") + + # Add the language to the active project + self._agent.add_language(language, wait_for_ls_startup=False) + + def _remove_language(self, request_remove_language: RequestRemoveLanguage) -> None: + from solidlsp.ls_config import Language + + # Convert string to Language enum + try: + language = Language(request_remove_language.language) + except ValueError: + raise ValueError(f"Invalid language: {request_remove_language.language}") + + # Remove the language from the active project + self._agent.remove_language(language) + @staticmethod def _find_first_free_port(start_port: int) -> int: port = start_port diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index c5dcdbd7..8b3bbaf5 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -175,6 +175,25 @@ class LanguageServerManager: raise ValueError(f"Language server for language {language.value} already present") return self._create_and_start_language_server(language) + def remove_language_server(self, language: Language, save_cache: bool = False) -> None: + """ + Removes the language server for the given language, stopping it if it is running. + + :param language: the language + """ + if language not in self._language_servers: + raise ValueError(f"No language server for language {language.value} present; cannot remove") + ls = self._language_servers.pop(language) + self._stop_language_server(ls, save_cache=save_cache) + + @staticmethod + def _stop_language_server(ls: SolidLanguageServer, save_cache: bool = False) -> None: + if ls.is_running(): + if save_cache: + ls.save_cache() + log.info(f"Stopping language server for language {ls.language} ...") + ls.stop() + def iter_language_servers(self) -> Iterator[SolidLanguageServer]: for ls in self._language_servers.values(): yield self._ensure_functional_ls(ls) @@ -186,11 +205,7 @@ class LanguageServerManager: :param save_cache: whether to save the cache before stopping """ for ls in self.iter_language_servers(): - if ls.is_running(): - if save_cache: - ls.save_cache() - log.info(f"Stopping language server for language {ls.language} ...") - ls.stop() + self._stop_language_server(ls, save_cache=save_cache) def save_all_caches(self) -> None: """ diff --git a/src/serena/project.py b/src/serena/project.py index 75b0d8e6..371f291c 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -416,6 +416,28 @@ class Project(ToStringMixin): log.info("Adding and starting the language server for new language %s ...", language.value) self.language_server_manager.add_language_server(language) + def remove_language(self, language: Language) -> None: + """ + Removes a programming language from the project configuration, stopping the corresponding + language server instance if the LS manager is active. + The project configuration is saved to disk after removing the language. + + :param language: the programming language to remove + """ + if language not in self.project_config.languages: + log.info(f"Language {language.value} is not present in the project configuration.") + return + # update the project configuration + self.project_config.languages.remove(language) + self.save_config() + + # stop the language server (if the LS manager is active) + if self.language_server_manager is None: + log.info("Language server manager is not active; skipping language server shutdown for the removed language.") + else: + log.info("Removing and stopping the language server for language %s ...", language.value) + self.language_server_manager.remove_language_server(language) + def shutdown(self) -> None: if self.language_server_manager is not None: self.language_server_manager.stop_all(save_cache=True) diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css index 6f26f466..d320f24c 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -618,3 +618,96 @@ body { min-width: 100%; } } + +/* Modal Styles */ +.modal { + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.5); +} + +.modal-content { + background-color: var(--bg-primary); + margin: 10% auto; + padding: 25px; + border: 1px solid var(--border-color); + border-radius: 8px; + width: 90%; + max-width: 500px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + position: relative; +} + +.modal-close { + color: var(--text-muted); + float: right; + font-size: 28px; + font-weight: bold; + line-height: 20px; + cursor: pointer; + transition: color 0.2s; +} + +.modal-close:hover, +.modal-close:focus { + color: var(--text-primary); +} + +.modal h3 { + margin-top: 0; + margin-bottom: 15px; + color: var(--text-primary); +} + +/* Language Badge Styles */ +.languages-container { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.language-badge { + position: relative; + display: inline-flex; + align-items: center; + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; +} + +.language-badge.removable { + padding-right: 28px; +} + +.language-remove { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 68, 68, 0.1); + border-radius: 3px; + cursor: pointer; + color: #ff4444; + font-size: 14px; + font-weight: bold; + line-height: 1; + transition: all 0.2s; +} + +.language-remove:hover { + background: rgba(255, 68, 68, 0.2); + transform: scale(1.1); +} diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index e63fdd29..fbb306b7 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -52,6 +52,9 @@ class Dashboard { // Page state this.currentPage = 'overview'; this.configData = null; + this.jetbrainsMode = false; + this.activeProjectName = null; + this.languageToRemove = null; // Tool names and stats this.toolNames = []; @@ -80,6 +83,18 @@ class Dashboard { this.$availableToolsDisplay = $('#available-tools-display'); this.$availableModesDisplay = $('#available-modes-display'); this.$availableContextsDisplay = $('#available-contexts-display'); + this.$addLanguageBtn = $('#add-language-btn'); + this.$addLanguageModal = $('#add-language-modal'); + this.$modalLanguageSelect = $('#modal-language-select'); + this.$modalProjectName = $('#modal-project-name'); + this.$modalAddBtn = $('#modal-add-btn'); + this.$modalCancelBtn = $('#modal-cancel-btn'); + this.$modalClose = $('.modal-close'); + this.$removeLanguageModal = $('#remove-language-modal'); + this.$removeLanguageName = $('#remove-language-name'); + this.$removeModalOkBtn = $('#remove-modal-ok-btn'); + this.$removeModalCancelBtn = $('#remove-modal-cancel-btn'); + this.$modalCloseRemove = $('.modal-close-remove'); // Chart references this.countChart = null; @@ -97,6 +112,13 @@ class Dashboard { this.$themeToggle.click(this.toggleTheme.bind(this)); this.$refreshStats.click(this.loadStats.bind(this)); this.$clearStats.click(this.clearStats.bind(this)); + this.$addLanguageBtn.click(this.openLanguageModal.bind(this)); + this.$modalAddBtn.click(this.addLanguageFromModal.bind(this)); + this.$modalCancelBtn.click(this.closeLanguageModal.bind(this)); + this.$modalClose.click(this.closeLanguageModal.bind(this)); + this.$removeModalOkBtn.click(this.confirmRemoveLanguageOk.bind(this)); + this.$removeModalCancelBtn.click(this.closeRemoveLanguageModal.bind(this)); + this.$modalCloseRemove.click(this.closeRemoveLanguageModal.bind(this)); // Page navigation $('[data-page]').click(function(e) { @@ -112,6 +134,19 @@ class Dashboard { } }); + // Close modals when clicking outside + this.$addLanguageModal.click(function(e) { + if ($(e.target).hasClass('modal')) { + self.closeLanguageModal(); + } + }); + + this.$removeLanguageModal.click(function(e) { + if ($(e.target).hasClass('modal')) { + self.closeRemoveLanguageModal(); + } + }); + // Collapsible sections $('.collapsible-header').click(function() { const $header = $(this); @@ -125,6 +160,17 @@ class Dashboard { // Initialize theme this.initializeTheme(); + // Add ESC key handler for closing modals + $(document).keydown(function(e) { + if (e.key === 'Escape' || e.keyCode === 27) { + if (self.$addLanguageModal.is(':visible')) { + self.closeLanguageModal(); + } else if (self.$removeLanguageModal.is(':visible')) { + self.closeRemoveLanguageModal(); + } + } + }); + // Initialize the application this.loadToolNames().then(function() { // Start on overview page @@ -187,8 +233,11 @@ class Dashboard { url: '/get_config_overview', type: 'GET', success: function(response) { + console.log('Config overview response:', response); self.failureCount = 0; self.configData = response; + self.jetbrainsMode = response.jetbrains_mode; + self.activeProjectName = response.active_project.name; self.displayConfig(response); self.displayBasicStats(response.tool_stats_summary); self.displayProjects(response.registered_projects); @@ -219,13 +268,14 @@ class Dashboard { } displayConfig(config) { - // Check if tools and memories sections are currently expanded - const $existingToolsContent = $('#tools-content'); - const $existingMemoriesContent = $('#memories-content'); - const wasToolsExpanded = $existingToolsContent.is(':visible'); - const wasMemoriesExpanded = $existingMemoriesContent.is(':visible'); + try { + // Check if tools and memories sections are currently expanded + const $existingToolsContent = $('#tools-content'); + const $existingMemoriesContent = $('#memories-content'); + const wasToolsExpanded = $existingToolsContent.is(':visible'); + const wasMemoriesExpanded = $existingMemoriesContent.is(':visible'); - let html = '