diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e869ba4a..2dd550e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,16 +16,18 @@ jobs: node-version: '18.x' - name: Install dependencies - run: npm install + run: | + npm install + pip install autoflake isort black ruff - name: Build VSCode Extension run: npm run compile - - name: Run VSCode Extension Formatter - run: npm run format + - name: Run Format Script for Python & TypeScript Files + run: ./scripts/format.sh - - name: Run VSCode Extension Linter - run: npm run lint + - name: Run Lint Script for Python & TypeScript Files + run: ./scripts/lint.sh - name: Run headless test uses: coactions/setup-xvfb@v1 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..7bd5f0bf --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 20.10.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eeaf678a..60358ad0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,4 +97,4 @@ git push origin feature/your-feature-name - [ZenML VSCode Extension Repository](https://github.com/zenml-io/vscode-zenml) - [ZenML Documentation](https://docs.zenml.io) -- [ZenML Slack Community](https://zenml.io/slack-invite) +- [ZenML Slack Community](https://zenml.io/slack) diff --git a/bundled/tool/lsp_jsonrpc.py b/bundled/tool/lsp_jsonrpc.py index 7eb79472..29d2e6b2 100644 --- a/bundled/tool/lsp_jsonrpc.py +++ b/bundled/tool/lsp_jsonrpc.py @@ -110,11 +110,11 @@ def close(self): """Closes the underlying streams.""" try: self._reader.close() - except: # pylint: disable=bare-except + except: # noqa: E722 # pylint: disable=bare-except pass try: self._writer.close() - except: # pylint: disable=bare-except + except: # noqa: E722 # pylint: disable=bare-except pass def send_data(self, data): @@ -146,7 +146,7 @@ def stop_all_processes(self): for i in self._rpc.values(): try: i.send_data({"id": str(uuid.uuid4()), "method": "exit"}) - except: # pylint: disable=bare-except + except: # noqa: E722 # pylint: disable=bare-except pass self._thread_pool.shutdown(wait=False) @@ -169,7 +169,7 @@ def _monitor_process(): del self._processes[workspace] rpc = self._rpc.pop(workspace) rpc.close() - except: # pylint: disable=bare-except + except: # noqa: E722 # pylint: disable=bare-except pass self._thread_pool.submit(_monitor_process) diff --git a/bundled/tool/lsp_runner.py b/bundled/tool/lsp_runner.py index b379ad2d..50b12f09 100644 --- a/bundled/tool/lsp_runner.py +++ b/bundled/tool/lsp_runner.py @@ -41,8 +41,8 @@ def update_sys_path(path_to_add: str, strategy: str) -> None: # pylint: disable=wrong-import-position,import-error -import lsp_jsonrpc as jsonrpc -import lsp_utils as utils +import lsp_jsonrpc as jsonrpc # noqa: E402 +import lsp_utils as utils # noqa: E402 RPC = jsonrpc.create_json_rpc(sys.stdin.buffer, sys.stdout.buffer) diff --git a/bundled/tool/lsp_server.py b/bundled/tool/lsp_server.py index 97f56d8f..69e5eb38 100644 --- a/bundled/tool/lsp_server.py +++ b/bundled/tool/lsp_server.py @@ -45,10 +45,10 @@ def update_sys_path(path_to_add: str, strategy: str) -> None: # Imports needed for the language server goes below this. # ********************************************************** # pylint: disable=wrong-import-position,import-error -import lsp_jsonrpc as jsonrpc -import lsprotocol.types as lsp -from lsp_zenml import ZenLanguageServer -from pygls import uris, workspace +import lsp_jsonrpc as jsonrpc # noqa: E402 +import lsprotocol.types as lsp # noqa: E402 +from lsp_zenml import ZenLanguageServer # noqa: E402 +from pygls import uris, workspace # noqa: E402 WORKSPACE_SETTINGS = {} GLOBAL_SETTINGS = {} @@ -56,9 +56,7 @@ def update_sys_path(path_to_add: str, strategy: str) -> None: MAX_WORKERS = 5 -LSP_SERVER = ZenLanguageServer( - name="zen-language-server", version="0.0.1", max_workers=MAX_WORKERS -) +LSP_SERVER = ZenLanguageServer(name="zen-language-server", version="0.0.1", max_workers=MAX_WORKERS) # ********************************************************** # Tool specific code goes below this. @@ -176,9 +174,7 @@ def get_cwd(settings: Dict[str, Any], document: Optional[workspace.Document]) -> # ***************************************************** # Logging and notification. # ***************************************************** -def log_to_output( - message: str, msg_type: lsp.MessageType = lsp.MessageType.Log -) -> None: +def log_to_output(message: str, msg_type: lsp.MessageType = lsp.MessageType.Log) -> None: """Log to output.""" LSP_SERVER.show_message_log(message, msg_type) diff --git a/bundled/tool/lsp_zenml.py b/bundled/tool/lsp_zenml.py index 095946c1..21374825 100644 --- a/bundled/tool/lsp_zenml.py +++ b/bundled/tool/lsp_zenml.py @@ -25,16 +25,14 @@ from functools import wraps import lsprotocol.types as lsp -from constants import MIN_ZENML_VERSION, TOOL_MODULE_NAME, IS_ZENML_INSTALLED +from constants import IS_ZENML_INSTALLED, MIN_ZENML_VERSION, TOOL_MODULE_NAME from lazy_import import suppress_stdout_temporarily from packaging.version import parse as parse_version from pygls.server import LanguageServer from zen_watcher import ZenConfigWatcher from zenml_client import ZenMLClient -zenml_init_error = { - "error": "ZenML is not initialized. Please check ZenML version requirements." -} +zenml_init_error = {"error": "ZenML is not initialized. Please check ZenML version requirements."} class ZenLanguageServer(LanguageServer): @@ -60,9 +58,7 @@ async def is_zenml_installed(self) -> bool: if process.returncode == 0: self.show_message_log("✅ ZenML installation check: Successful.") return True - self.show_message_log( - "❌ ZenML installation check failed.", lsp.MessageType.Error - ) + self.show_message_log("❌ ZenML installation check failed.", lsp.MessageType.Error) return False except Exception as e: self.show_message_log( @@ -91,15 +87,13 @@ async def initialize_zenml_client(self): self.log_to_output("🚀 Initializing ZenML client...") try: self.zenml_client = ZenMLClient() - self.notify_user("✅ ZenML client initialized successfully.") + self.show_message_log("✅ ZenML client initialized successfully.") # register pytool module commands self.register_commands() # initialize watcher self.initialize_global_config_watcher() except Exception as e: - self.notify_user( - f"Failed to initialize ZenML client: {str(e)}", lsp.MessageType.Error - ) + self.notify_user(f"Failed to initialize ZenML client: {str(e)}", lsp.MessageType.Error) def initialize_global_config_watcher(self): """Sets up and starts the Global Configuration Watcher.""" @@ -139,9 +133,7 @@ def wrapper(*args, **kwargs): with suppress_stdout_temporarily(): if wrapper_name: - wrapper_instance = getattr( - self.zenml_client, wrapper_name, None - ) + wrapper_instance = getattr(self.zenml_client, wrapper_name, None) if not wrapper_instance: return {"error": f"Wrapper '{wrapper_name}' not found."} return func(wrapper_instance, *args, **kwargs) @@ -185,33 +177,25 @@ def _construct_version_validation_response(self, meets_requirement, version_str) def send_custom_notification(self, method: str, args: dict): """Sends a custom notification to the LSP client.""" - self.show_message_log( - f"Sending custom notification: {method} with args: {args}" - ) + self.show_message_log(f"Sending custom notification: {method} with args: {args}") self.send_notification(method, args) def update_python_interpreter(self, interpreter_path): """Updates the Python interpreter path and handles errors.""" try: self.python_interpreter = interpreter_path - self.show_message_log( - f"LSP_Python_Interpreter Updated: {self.python_interpreter}" - ) + self.show_message_log(f"LSP_Python_Interpreter Updated: {self.python_interpreter}") # pylint: disable=broad-exception-caught except Exception as e: self.show_message_log( f"Failed to update Python interpreter: {str(e)}", lsp.MessageType.Error ) - def notify_user( - self, message: str, msg_type: lsp.MessageType = lsp.MessageType.Info - ): + def notify_user(self, message: str, msg_type: lsp.MessageType = lsp.MessageType.Info): """Logs a message and also notifies the user.""" self.show_message(message, msg_type) - def log_to_output( - self, message: str, msg_type: lsp.MessageType = lsp.MessageType.Log - ) -> None: + def log_to_output(self, message: str, msg_type: lsp.MessageType = lsp.MessageType.Log) -> None: """Log to output.""" self.show_message_log(message, msg_type) @@ -264,7 +248,6 @@ def get_active_stack(wrapper_instance, *args, **kwargs): @self.zenml_command(wrapper_name="stacks_wrapper") def set_active_stack(wrapper_instance, args): """Sets the active ZenML stack to the specified stack.""" - print(f"args received: {args}") return wrapper_instance.set_active_stack(args) @self.command(f"{TOOL_MODULE_NAME}.renameStack") diff --git a/bundled/tool/zenml_wrappers.py b/bundled/tool/zenml_wrappers.py index 12946c54..7ecfc845 100644 --- a/bundled/tool/zenml_wrappers.py +++ b/bundled/tool/zenml_wrappers.py @@ -47,9 +47,7 @@ def get_global_config_directory(self): def RestZenStoreConfiguration(self): """Returns the RestZenStoreConfiguration class for store configuration.""" # pylint: disable=not-callable - return self.lazy_import( - "zenml.zen_stores.rest_zen_store", "RestZenStoreConfiguration" - ) + return self.lazy_import("zenml.zen_stores.rest_zen_store", "RestZenStoreConfiguration") def get_global_config_directory_path(self) -> str: """Get the global configuration directory path. @@ -176,9 +174,7 @@ def get_server_info(self) -> dict: store_info = json.loads(self.gc.zen_store.get_store_info().json(indent=2)) # Handle both 'store' and 'store_configuration' depending on version store_attr_name = ( - "store_configuration" - if hasattr(self.gc, "store_configuration") - else "store" + "store_configuration" if hasattr(self.gc, "store_configuration") else "store" ) store_config = json.loads(getattr(self.gc, store_attr_name).json(indent=2)) return {"storeInfo": store_info, "storeConfig": store_config} @@ -200,9 +196,7 @@ def connect(self, args, **kwargs) -> dict: try: # pylint: disable=not-callable access_token = self.web_login(url=url, verify_ssl=verify_ssl) - self._config_wrapper.set_store_configuration( - remote_url=url, access_token=access_token - ) + self._config_wrapper.set_store_configuration(remote_url=url, access_token=access_token) return {"message": "Connected successfully.", "access_token": access_token} except self.AuthorizationException as e: return {"error": f"Authorization failed: {str(e)}"} @@ -218,9 +212,7 @@ def disconnect(self, args) -> dict: try: # Adjust for changes from 'store' to 'store_configuration' store_attr_name = ( - "store_configuration" - if hasattr(self.gc, "store_configuration") - else "store" + "store_configuration" if hasattr(self.gc, "store_configuration") else "store" ) url = getattr(self.gc, store_attr_name).url store_type = self.BaseZenStore.get_store_type(url) @@ -289,21 +281,15 @@ def fetch_pipeline_runs(self, args): "version": run.body.pipeline.body.version, "stackName": run.body.stack.name, "startTime": ( - run.metadata.start_time.isoformat() - if run.metadata.start_time - else None + run.metadata.start_time.isoformat() if run.metadata.start_time else None ), "endTime": ( - run.metadata.end_time.isoformat() - if run.metadata.end_time - else None + run.metadata.end_time.isoformat() if run.metadata.end_time else None ), "os": run.metadata.client_environment.get("os", "Unknown OS"), "osVersion": run.metadata.client_environment.get( "os_version", - run.metadata.client_environment.get( - "mac_version", "Unknown Version" - ), + run.metadata.client_environment.get("mac_version", "Unknown Version"), ), "pythonVersion": run.metadata.client_environment.get( "python_version", "Unknown" @@ -378,12 +364,11 @@ def ZenKeyError(self) -> Any: def fetch_stacks(self, args): """Fetches all ZenML stacks and components with pagination.""" - page = args[0] - max_size = args[1] + if len(args) < 2: + return {"error": "Insufficient arguments provided."} + page, max_size = args try: - stacks_page = self.client.list_stacks( - page=page, size=max_size, hydrate=True - ) + stacks_page = self.client.list_stacks(page=page, size=max_size, hydrate=True) stacks_data = self.process_stacks(stacks_page.items) return { @@ -496,23 +481,17 @@ def copy_stack(self, args) -> dict: target_stack_name = args[1] if not source_stack_name_or_id or not target_stack_name: - return { - "error": "Both source stack name/id and target stack name are required" - } + return {"error": "Both source stack name/id and target stack name are required"} try: - stack_to_copy = self.client.get_stack( - name_id_or_prefix=source_stack_name_or_id - ) + stack_to_copy = self.client.get_stack(name_id_or_prefix=source_stack_name_or_id) component_mapping = { c_type: [c.id for c in components][0] for c_type, components in stack_to_copy.components.items() if components } - self.client.create_stack( - name=target_stack_name, components=component_mapping - ) + self.client.create_stack(name=target_stack_name, components=component_mapping) return { "message": ( f"Stack `{source_stack_name_or_id}` successfully copied " diff --git a/package.json b/package.json index 6bce68c4..4c001689 100644 --- a/package.json +++ b/package.json @@ -345,7 +345,6 @@ "@types/fs-extra": "^11.0.4", "@types/mocha": "^10.0.6", "@types/node": "^18.19.18", - "@types/proxyquire": "^1.3.31", "@types/sinon": "^17.0.3", "@types/vscode": "^1.86.0", "@types/webpack": "^5.28.5", @@ -357,14 +356,9 @@ "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "prettier": "^3.2.5", - "proxyquire": "^2.1.3", "sinon": "^17.0.1", "ts-loader": "^9.5.1", - "ts-mockito": "^2.6.1", "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "tsconfig-paths-webpack-plugin": "^4.1.0", - "typemoq": "^2.1.0", "typescript": "^5.3.3", "webpack": "^5.90.0", "webpack-cli": "^5.1.4" diff --git a/package.nls.json b/package.nls.json index f2e046db..99e0ed37 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,6 +1,6 @@ { "extension.description": "Integrates ZenML directly into VS Code, enhancing machine learning workflow with support for pipelines, stacks, and server management.", - "zenml.promptForInterpreter": "Prompt to select a Python interpreter from the command palette in VSCode.", + "command.promptForInterpreter": "Prompt to select a Python interpreter from the command palette in VSCode.", "command.connectServer": "Establishes a connection to a specified ZenML server.", "command.disconnectServer": "Disconnects from the currently connected ZenML server.", "command.refreshServerStatus": "Refreshes the status of the ZenML server to reflect the current state.", diff --git a/requirements.in b/requirements.in index 35b2285a..92dc043b 100644 --- a/requirements.in +++ b/requirements.in @@ -2,7 +2,7 @@ # NOTE: # Use Python 3.8 or greater which ever is the minimum version of the python # you plan on supporting when creating the environment or using pip-tools. -# Only run the commands below to manully upgrade packages in requirements.txt: +# Only run the commands below to manually upgrade packages in requirements.txt: # 1) python -m pip install pip-tools # 2) pip-compile --generate-hashes --resolver=backtracking --upgrade ./requirements.in # If you are using nox commands to setup or build package you don't need to @@ -13,4 +13,4 @@ pygls packaging # Tool-specific packages for ZenML extension watchdog -PyYAML +PyYAML \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 47a77428..a6c758dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,9 +24,9 @@ packaging==24.0 \ --hash=sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5 \ --hash=sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9 # via -r ./requirements.in -pygls==1.3.0 \ - --hash=sha256:1b44ace89c9382437a717534f490eadc6fda7c0c6c16ac1eaaf5568e345e4fb8 \ - --hash=sha256:d4a01414b6ed4e34e7e8fd29b77d3e88c29615df7d0bbff49bf019e15ec04b8f +pygls==1.3.1 \ + --hash=sha256:140edceefa0da0e9b3c533547c892a42a7d2fd9217ae848c330c53d266a55018 \ + --hash=sha256:6e00f11efc56321bdeb6eac04f6d86131f654c7d49124344a9ebb968da3dd91e # via -r ./requirements.in pyyaml==6.0.1 \ --hash=sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5 \ diff --git a/scripts/format.sh b/scripts/format.sh index 20e0aef8..6f704007 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -4,21 +4,12 @@ set -euxo pipefail # Source directory for Python tool python_src="bundled/tool" -# Process arguments -for arg in "$@"; do - if [ "$arg" = "--dry-run" ]; then - echo "Dry run mode enabled" - dry_run=true - fi -done - echo "Formatting Python files in bundled/tool..." find $python_src -name "*.py" -print0 | xargs -0 autoflake --in-place --remove-all-unused-imports find $python_src -name "*.py" -print0 | xargs -0 isort -- find $python_src -name "*.py" -print0 | xargs -0 black --line-length 100 -- -# For now, Typescript formatting is separate from Python -# echo "Formatting TypeScript files..." -# npx prettier --ignore-path .gitignore --write "**/*.+(ts|json)" +echo "Formatting TypeScript files..." +npx prettier --ignore-path .gitignore --write "**/*.+(ts|json)" echo "Formatting complete." diff --git a/scripts/lint.sh b/scripts/lint.sh index e8acc6d6..30ea8ac8 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -1,10 +1,18 @@ #!/bin/bash +set -euxo pipefail cd "$(dirname "$0")/.." || exit -PYTHONPATH="$PYTHONPATH:$(pwd)/bundled/tool" +# Set PYTHONPATH to include bundled/tool +PYTHONPATH="${PYTHONPATH-}:$(pwd)/bundled/tool" export PYTHONPATH -nox --session lint +# Lint Python files with ruff +echo "Linting Python files..." +ruff bundled/tool || { echo "Linting Python files failed"; exit 1; } + +# Lint TypeScript files with eslint +echo "Linting TypeScript files..." +npx eslint 'src/**/*.ts' || { echo "Linting TypeScript files failed"; exit 1; } unset PYTHONPATH diff --git a/src/commands/pipelines/registry.ts b/src/commands/pipelines/registry.ts index 88b18159..fbbfb80d 100644 --- a/src/commands/pipelines/registry.ts +++ b/src/commands/pipelines/registry.ts @@ -34,9 +34,16 @@ export const registerPipelineCommands = (context: ExtensionContext) => { 'zenml.deletePipelineRun', async (node: PipelineTreeItem) => await pipelineCommands.deletePipelineRun(node) ), - registerCommand('zenml.nextPipelineRunsPage', async () => pipelineDataProvider.goToNextPage()), - registerCommand('zenml.previousPipelineRunsPage', async () => pipelineDataProvider.goToPreviousPage()), - registerCommand("zenml.setPipelineRunsPerPage", async () => await pipelineDataProvider.updateItemsPerPage()), + registerCommand('zenml.nextPipelineRunsPage', async () => + pipelineDataProvider.goToNextPage() + ), + registerCommand('zenml.previousPipelineRunsPage', async () => + pipelineDataProvider.goToPreviousPage() + ), + registerCommand( + 'zenml.setPipelineRunsPerPage', + async () => await pipelineDataProvider.updateItemsPerPage() + ), ]; registeredCommands.forEach(cmd => { diff --git a/src/commands/server/cmds.ts b/src/commands/server/cmds.ts index 8224828c..a6ecf9b7 100644 --- a/src/commands/server/cmds.ts +++ b/src/commands/server/cmds.ts @@ -18,7 +18,6 @@ import { RestServerConnectionResponse, } from '../../types/LSClientResponseTypes'; import { updateServerUrlAndToken } from '../../utils/global'; -import { showInformationMessage } from '../../utils/notifications'; import { refreshUtils } from '../../utils/refresh'; import { ServerDataProvider } from '../../views/activityBar'; import { promptAndStoreServerUrl } from './utils'; @@ -41,7 +40,7 @@ const connectServer = async (): Promise => { { location: vscode.ProgressLocation.Notification, title: 'Connecting to ZenML server...', - cancellable: false, + cancellable: true, }, async progress => { try { @@ -80,7 +79,7 @@ const disconnectServer = async (): Promise => { { location: vscode.ProgressLocation.Notification, title: 'Disconnecting from ZenML server...', - cancellable: false, + cancellable: true, }, async progress => { try { @@ -114,7 +113,6 @@ const refreshServerStatus = async (): Promise => { }, async () => { await ServerDataProvider.getInstance().refresh(); - showInformationMessage('Server status refreshed.'); } ); }; diff --git a/src/commands/stack/registry.ts b/src/commands/stack/registry.ts index eba47594..b7626a64 100644 --- a/src/commands/stack/registry.ts +++ b/src/commands/stack/registry.ts @@ -25,7 +25,10 @@ export const registerStackCommands = (context: ExtensionContext) => { const stackDataProvider = StackDataProvider.getInstance(); try { const registeredCommands = [ - registerCommand("zenml.setStackItemsPerPage", async () => await stackDataProvider.updateItemsPerPage()), + registerCommand( + 'zenml.setStackItemsPerPage', + async () => await stackDataProvider.updateItemsPerPage() + ), registerCommand('zenml.refreshStackView', async () => await stackCommands.refreshStackView()), registerCommand( 'zenml.refreshActiveStack', diff --git a/src/commands/stack/utils.ts b/src/commands/stack/utils.ts index 5ec2321f..95af943d 100644 --- a/src/commands/stack/utils.ts +++ b/src/commands/stack/utils.ts @@ -60,8 +60,7 @@ export const getActiveStack = async (): Promise<{ id: string; name: string } | u } return result; } catch (error: any) { - console.error(`Error getting active stack information: ${error}`); - showErrorMessage(`Failed to get active stack information: ${error.message}`); + console.error(`Failed to get active stack information: ${error}`); return undefined; } }; diff --git a/src/common/python.ts b/src/common/python.ts index 621e9f74..a873f984 100644 --- a/src/common/python.ts +++ b/src/common/python.ts @@ -33,6 +33,11 @@ async function getPythonExtensionAPI(): Promise { return _api; } +/** + * Initialize the python extension. + * + * @param disposables - List of disposables to be disposed when the extension is deactivated. + */ export async function initializePython(disposables: Disposable[]): Promise { try { const api = await getPythonExtensionAPI(); @@ -52,6 +57,12 @@ export async function initializePython(disposables: Disposable[]): Promise } } +/** + * Resolve the python interpreter. + * + * @param interpreter - The interpreter to resolve. + * @returns The resolved environment. + */ export async function resolveInterpreter( interpreter: string[] ): Promise { @@ -59,6 +70,12 @@ export async function resolveInterpreter( return api?.environments.resolveEnvironment(interpreter[0]); } +/** + * Get the interpreter details. + * + * @param resource - The resource to get the interpreter details from. + * @returns The interpreter details. + */ export async function getInterpreterDetails(resource?: Uri): Promise { const api = await getPythonExtensionAPI(); const environment = await api?.environments.resolveEnvironment( @@ -70,16 +87,34 @@ export async function getInterpreterDetails(resource?: Uri): Promise { const api = await getPythonExtensionAPI(); return api?.debug.getDebuggerPackagePath(); } +/** + * Run a python extension command. + * + * @param command - The command to run. + * @param rest - The rest of the arguments. + * @returns The result of the command. + */ export async function runPythonExtensionCommand(command: string, ...rest: any[]) { await getPythonExtensionAPI(); return await commands.executeCommand(command, ...rest); } +/** + * Check if the python version is supported. + * + * @param resolved - The resolved environment. + * @returns True if the version is supported, false otherwise. + */ export function checkVersion(resolved: ResolvedEnvironment | undefined): boolean { const version = resolved?.version; if (version?.major === 3 && version?.minor >= 8) { @@ -92,7 +127,13 @@ export function checkVersion(resolved: ResolvedEnvironment | undefined): boolean return false; } -export function isPythonVersonSupported(resolvedEnv: ResolvedEnvironment | undefined): { +/** + * Check if the python version is supported. + * + * @param resolvedEnv - The resolved environment. + * @returns An object with the result and an optional message. + */ +export function isPythonVersionSupported(resolvedEnv: ResolvedEnvironment | undefined): { isSupported: boolean; message?: string; } { diff --git a/src/services/LSClient.ts b/src/services/LSClient.ts index ccd52939..1c1c4b2d 100644 --- a/src/services/LSClient.ts +++ b/src/services/LSClient.ts @@ -28,7 +28,6 @@ import { import { getZenMLServerUrl, updateServerUrlAndToken } from '../utils/global'; import { debounce } from '../utils/refresh'; import { EventBus } from './EventBus'; -import { ServerDataProvider } from '../views/activityBar'; export class LSClient { private static instance: LSClient | null = null; @@ -85,7 +84,11 @@ export class LSClient { */ public handleZenMLInstalled(params: { is_installed: boolean; version?: string }): void { console.log(`Received ${LSP_IS_ZENML_INSTALLED} notification: `, params.is_installed); - this.localZenML = params; + this.localZenML = { + is_installed: params.is_installed, + version: params.version || '', + }; + this.eventBus.emit(LSP_IS_ZENML_INSTALLED, this.localZenML); this.eventBus.emit(REFRESH_ENVIRONMENT_VIEW); } @@ -157,6 +160,7 @@ export class LSClient { * Handles the zenml/stackChanged notification. * * @param activeStackId The ID of the active stack. + * @returns A promise resolving to void. */ public async handleStackChanged(activeStackId: string): Promise { console.log(`Received ${LSP_ZENML_STACK_CHANGED} notification:`, activeStackId); diff --git a/src/services/ZenExtension.ts b/src/services/ZenExtension.ts index 7d1f1f48..dd964d06 100644 --- a/src/services/ZenExtension.ts +++ b/src/services/ZenExtension.ts @@ -22,7 +22,7 @@ import { registerLogger, traceLog, traceVerbose } from '../common/log/logging'; import { IInterpreterDetails, initializePython, - isPythonVersonSupported, + isPythonVersionSupported, onDidChangePythonInterpreter, resolveInterpreter, } from '../common/python'; @@ -113,7 +113,9 @@ export class ZenExtension { return; } - ZenMLStatusBar.getInstance(); + const zenmlStatusBar = ZenMLStatusBar.getInstance(); + zenmlStatusBar.registerCommands(); + this.dataProviders.forEach((provider, viewId) => { const view = vscode.window.createTreeView(viewId, { treeDataProvider: provider }); this.viewDisposables.push(view); @@ -132,7 +134,7 @@ export class ZenExtension { this.interpreterCheckInProgress = true; if (interpreterDetails.path) { const resolvedEnv = await resolveInterpreter(interpreterDetails.path); - const { isSupported, message } = isPythonVersonSupported(resolvedEnv); + const { isSupported, message } = isPythonVersionSupported(resolvedEnv); if (!isSupported) { vscode.window.showErrorMessage(`Interpreter not supported: ${message}`); this.interpreterCheckInProgress = false; diff --git a/src/types/PipelineTypes.ts b/src/types/PipelineTypes.ts index 96ca4930..17f1a7fb 100644 --- a/src/types/PipelineTypes.ts +++ b/src/types/PipelineTypes.ts @@ -13,7 +13,6 @@ import { ErrorMessageResponse, VersionMismatchError } from './LSClientResponseTypes'; - interface PipelineRunsData { runs: PipelineRun[]; total: number; diff --git a/src/types/StackTypes.ts b/src/types/StackTypes.ts index ca487dd5..5ada4087 100644 --- a/src/types/StackTypes.ts +++ b/src/types/StackTypes.ts @@ -42,6 +42,6 @@ interface StackComponent { type: string; } -export type StacksReponse = StacksData | ErrorMessageResponse | VersionMismatchError; +export type StacksResponse = StacksData | ErrorMessageResponse | VersionMismatchError; export { Stack, Components, StackComponent, StacksData }; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 6d83407c..359ce0ff 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -31,6 +31,8 @@ export const LSP_ZENML_REQUIREMENTS_NOT_MET = 'zenml/requirementsNotMet'; // EventBus emitted events export const LSCLIENT_READY = 'lsClientReady'; export const LSCLIENT_STATE_CHANGED = 'lsClientStateChanged'; +export const ZENML_CLIENT_STATE_CHANGED = 'zenmlClientStateChanged'; + export const REFRESH_ENVIRONMENT_VIEW = 'refreshEnvironmentView'; export const REFRESH_SERVER_STATUS = 'refreshServerStatus'; diff --git a/src/views/activityBar/common/ErrorTreeItem.ts b/src/views/activityBar/common/ErrorTreeItem.ts index 980e2858..8bac033a 100644 --- a/src/views/activityBar/common/ErrorTreeItem.ts +++ b/src/views/activityBar/common/ErrorTreeItem.ts @@ -10,7 +10,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express // or implied.See the License for the specific language governing // permissions and limitations under the License. -import { ThemeIcon, TreeItem } from 'vscode'; +import { ThemeColor, ThemeIcon, TreeItem } from 'vscode'; export interface GenericErrorTreeItem { label: string; @@ -25,17 +25,23 @@ export class ErrorTreeItem extends TreeItem { constructor(label: string, description: string) { super(label); this.description = description; - this.iconPath = new ThemeIcon('error'); + this.iconPath = new ThemeIcon('warning', new ThemeColor('charts.yellow')); } } export class VersionMismatchTreeItem extends ErrorTreeItem { constructor(clientVersion: string, serverVersion: string) { super(`Version mismatch detected`, `Client: ${clientVersion} – Server: ${serverVersion}`); - this.iconPath = new ThemeIcon('warning'); + this.iconPath = new ThemeIcon('warning', new ThemeColor('charts.yellow')); } } +/** + * Creates an error item for the given error. + * + * @param error The error to create an item for. + * @returns The error tree item(s). + */ export const createErrorItem = (error: any): TreeItem[] => { const errorItems: TreeItem[] = []; console.log('Creating error item', error); @@ -45,3 +51,29 @@ export const createErrorItem = (error: any): TreeItem[] => { errorItems.push(new ErrorTreeItem(error.errorType || 'Error', error.message)); return errorItems; }; + +/** + * Creates an error item for authentication errors. + * + * @param errorMessage The error message to parse. + * @returns The error tree item(s), + */ +export const createAuthErrorItem = (errorMessage: string): ErrorTreeItem[] => { + const parts = errorMessage.split(':').map(part => part.trim()); + let [generalError, detailedError, actionSuggestion] = ['', '', '']; + + if (parts.length > 2) { + generalError = parts[0]; // "Failed to retrieve pipeline runs" + detailedError = `${parts[1]}: ${(parts[2].split('.')[0] || '').trim()}`; // "Authentication error: error decoding access token" + actionSuggestion = (parts[2].split('. ')[1] || '').trim(); // "You may need to rerun zenml connect" + } + + const errorItems: ErrorTreeItem[] = []; + if (detailedError) { + errorItems.push(new ErrorTreeItem(parts[1], detailedError.split(':')[1].trim())); + } + if (actionSuggestion) { + errorItems.push(new ErrorTreeItem(actionSuggestion, '')); + } + return errorItems; +}; diff --git a/src/views/activityBar/common/LoadingTreeItem.ts b/src/views/activityBar/common/LoadingTreeItem.ts index 61317ba0..9e0fe9b6 100644 --- a/src/views/activityBar/common/LoadingTreeItem.ts +++ b/src/views/activityBar/common/LoadingTreeItem.ts @@ -13,19 +13,18 @@ import { TreeItem, TreeItemCollapsibleState, ThemeIcon } from 'vscode'; export class LoadingTreeItem extends TreeItem { - constructor(message: string) { + constructor(message: string, description: string = 'Refreshing...') { super(message, TreeItemCollapsibleState.None); - this.description = 'Refreshing...'; + this.description = description; this.iconPath = new ThemeIcon('sync~spin'); } } -// create a MAP of loading tree items and labels export const LOADING_TREE_ITEMS = new Map([ ['server', new LoadingTreeItem('Refreshing Server View...')], ['stacks', new LoadingTreeItem('Refreshing Stacks View...')], ['pipelineRuns', new LoadingTreeItem('Refreshing Pipeline Runs...')], ['environment', new LoadingTreeItem('Refreshing Environments...')], - ['lsClient', new LoadingTreeItem('Waiting for Language Server to start...')], - ['zenmlClient', new LoadingTreeItem('Waiting for ZenML Client to initialize...')], + ['lsClient', new LoadingTreeItem('Waiting for Language Server to start...', '')], + ['zenmlClient', new LoadingTreeItem('Waiting for ZenML Client to initialize...', '')], ]); diff --git a/src/views/activityBar/common/PaginationTreeItems.ts b/src/views/activityBar/common/PaginationTreeItems.ts index fd337ab0..59ce4c20 100644 --- a/src/views/activityBar/common/PaginationTreeItems.ts +++ b/src/views/activityBar/common/PaginationTreeItems.ts @@ -36,11 +36,11 @@ export class CommandTreeItem extends TreeItem { export class SetItemsPerPageTreeItem extends TreeItem { constructor() { - super("Set items per page", TreeItemCollapsibleState.None); - this.tooltip = "Click to set the number of items shown per page"; + super('Set items per page', TreeItemCollapsibleState.None); + this.tooltip = 'Click to set the number of items shown per page'; this.command = { - command: "zenml.setStacksPerPage", - title: "Set Stack Items Per Page", + command: 'zenml.setStacksPerPage', + title: 'Set Stack Items Per Page', arguments: [], }; } diff --git a/src/views/activityBar/environmentView/EnvironmentDataProvider.ts b/src/views/activityBar/environmentView/EnvironmentDataProvider.ts index d613fff7..215773a5 100644 --- a/src/views/activityBar/environmentView/EnvironmentDataProvider.ts +++ b/src/views/activityBar/environmentView/EnvironmentDataProvider.ts @@ -13,14 +13,21 @@ import { EventEmitter, TreeDataProvider, TreeItem } from 'vscode'; import { State } from 'vscode-languageclient'; import { EventBus } from '../../../services/EventBus'; -import { LSCLIENT_STATE_CHANGED, REFRESH_ENVIRONMENT_VIEW } from '../../../utils/constants'; +import { + LSCLIENT_STATE_CHANGED, + LSP_IS_ZENML_INSTALLED, + REFRESH_ENVIRONMENT_VIEW, + LSP_ZENML_CLIENT_INITIALIZED, +} from '../../../utils/constants'; import { EnvironmentItem } from './EnvironmentItem'; import { createInterpreterDetails, createLSClientItem, createWorkspaceSettingsItems, - createZenMLStatusItems, + createZenMLInstallationItem, + createZenMLClientStatusItem, } from './viewHelpers'; +import { LSNotificationIsZenMLInstalled } from '../../../types/LSNotificationTypes'; export class EnvironmentDataProvider implements TreeDataProvider { private static instance: EnvironmentDataProvider | null = null; @@ -28,15 +35,20 @@ export class EnvironmentDataProvider implements TreeDataProvider { readonly onDidChangeTreeData = this._onDidChangeTreeData.event; private lsClientStatus: State = State.Stopped; + private zenmlClientReady: boolean = false; + private zenmlInstallationStatus: LSNotificationIsZenMLInstalled | null = null; private eventBus = EventBus.getInstance(); constructor() { - this.eventBus.off(LSCLIENT_STATE_CHANGED, this.handleLsClientStateChangey.bind(this)); - this.eventBus.on(LSCLIENT_STATE_CHANGED, this.handleLsClientStateChangey.bind(this)); + this.subscribeToEvents(); + } - this.eventBus.off(REFRESH_ENVIRONMENT_VIEW, () => this.refresh()); - this.eventBus.on(REFRESH_ENVIRONMENT_VIEW, () => this.refresh()); + private subscribeToEvents() { + this.eventBus.on(LSCLIENT_STATE_CHANGED, this.handleLsClientStateChange.bind(this)); + this.eventBus.on(LSP_ZENML_CLIENT_INITIALIZED, this.handleZenMLClientStateChange.bind(this)); + this.eventBus.on(LSP_IS_ZENML_INSTALLED, this.handleIsZenMLInstalled.bind(this)); + this.eventBus.on(REFRESH_ENVIRONMENT_VIEW, this.refresh.bind(this)); } /** @@ -51,13 +63,45 @@ export class EnvironmentDataProvider implements TreeDataProvider { return this.instance; } + /** + * Explicitly trigger loading state for ZenML installation check and ZenML client initialization. + */ + private triggerLoadingStateForZenMLChecks() { + this.zenmlClientReady = false; + this.zenmlInstallationStatus = null; + this.refresh(); + } + /** * Handles the change in the LSP client state. * * @param {State} status The new LSP client state. */ - private handleLsClientStateChangey(status: State) { + private handleLsClientStateChange(status: State) { this.lsClientStatus = status; + if (status !== State.Running) { + this.triggerLoadingStateForZenMLChecks(); + } + this.refresh(); + } + + /** + * Handles the change in the ZenML client state. + * + * @param {boolean} isReady The new ZenML client state. + */ + private handleZenMLClientStateChange(isReady: boolean) { + this.zenmlClientReady = isReady; + this.refresh(); + } + + /** + * Handles the change in the ZenML installation status. + * + * @param {LSNotificationIsZenMLInstalled} params The new ZenML installation status. + */ + private handleIsZenMLInstalled(params: LSNotificationIsZenMLInstalled) { + this.zenmlInstallationStatus = params; this.refresh(); } @@ -82,13 +126,13 @@ export class EnvironmentDataProvider implements TreeDataProvider { * Adjusts createRootItems to set each item to not collapsible and directly return items for Interpreter, Workspace, etc. */ private async createRootItems(): Promise { - const items: EnvironmentItem[] = []; - const lsClientStatusItem = createLSClientItem(this.lsClientStatus); - items.push(lsClientStatusItem); - items.push(...(await createZenMLStatusItems())); - items.push(...(await createInterpreterDetails())); - items.push(...(await createWorkspaceSettingsItems())); - + const items: EnvironmentItem[] = [ + createLSClientItem(this.lsClientStatus), + createZenMLInstallationItem(this.zenmlInstallationStatus), + createZenMLClientStatusItem(this.zenmlClientReady), + ...(await createInterpreterDetails()), + ...(await createWorkspaceSettingsItems()), + ]; return items; } diff --git a/src/views/activityBar/environmentView/EnvironmentItem.ts b/src/views/activityBar/environmentView/EnvironmentItem.ts index 6ced0300..b43d5131 100644 --- a/src/views/activityBar/environmentView/EnvironmentItem.ts +++ b/src/views/activityBar/environmentView/EnvironmentItem.ts @@ -11,7 +11,7 @@ // or implied.See the License for the specific language governing // permissions and limitations under the License. import path from 'path'; -import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; export class EnvironmentItem extends TreeItem { constructor( @@ -34,7 +34,18 @@ export class EnvironmentItem extends TreeItem { */ private determineIcon(label: string): { light: string; dark: string } | ThemeIcon | undefined { if (this.customIcon) { - return new ThemeIcon(this.customIcon); + switch (this.customIcon) { + case 'check': + return new ThemeIcon('check', new ThemeColor('gitDecoration.addedResourceForeground')); + case 'close': + return new ThemeIcon('close', new ThemeColor('gitDecoration.deletedResourceForeground')); + case 'error': + return new ThemeIcon('error', new ThemeColor('errorForeground')); + case 'warning': + return new ThemeIcon('warning', new ThemeColor('charts.yellow')); + default: + return new ThemeIcon(this.customIcon); + } } switch (label) { case 'Workspace': diff --git a/src/views/activityBar/environmentView/viewHelpers.ts b/src/views/activityBar/environmentView/viewHelpers.ts index 96dd386d..ef3f0e28 100644 --- a/src/views/activityBar/environmentView/viewHelpers.ts +++ b/src/views/activityBar/environmentView/viewHelpers.ts @@ -18,6 +18,7 @@ import { PYTOOL_MODULE } from '../../../utils/constants'; import { getProjectRoot } from '../../../common/utilities'; import { LSClient } from '../../../services/LSClient'; import { State } from 'vscode-languageclient'; +import { LSNotificationIsZenMLInstalled } from '../../../types/LSNotificationTypes'; /** * Creates the LSP client item for the environment view. @@ -45,27 +46,50 @@ export function createLSClientItem(lsClientStatus: State): EnvironmentItem { /** * Creates the ZenML status items for the environment view. * - * @returns {Promise} The ZenML status items. + * @returns {EnvironmentItem} The ZenML status items. */ -export async function createZenMLStatusItems(): Promise { - const zenmlReady = LSClient.getInstance().isZenMLReady; +export function createZenMLClientStatusItem(zenmlClientReady: boolean): EnvironmentItem { const localZenML = LSClient.getInstance().localZenML; - const zenMLLocalInstallationItem = new EnvironmentItem( - 'ZenML Local', - localZenML.is_installed ? `${localZenML.version}` : 'Not found', - TreeItemCollapsibleState.None, - localZenML.is_installed ? 'check' : 'warning' - ); - const zenMLClientStatusItem = new EnvironmentItem( 'ZenML Client', - !localZenML.is_installed ? '' : zenmlReady ? 'Initialized' : 'Awaiting Initialization', + !localZenML.is_installed ? '' : zenmlClientReady ? 'Initialized' : 'Awaiting Initialization', TreeItemCollapsibleState.None, - !localZenML.is_installed ? 'error' : zenmlReady ? 'check' : 'sync~spin' + !localZenML.is_installed ? 'warning' : zenmlClientReady ? 'check' : 'sync~spin' ); - return [zenMLLocalInstallationItem, zenMLClientStatusItem]; + return zenMLClientStatusItem; +} + +/** + * Creates the ZenML installation item for the environment view. + * + * @param installationStatus The installation status of ZenML. + * @returns {EnvironmentItem} The ZenML installation item. + */ +export function createZenMLInstallationItem( + installationStatus: LSNotificationIsZenMLInstalled | null +): EnvironmentItem { + if (!installationStatus) { + return new EnvironmentItem( + 'ZenML Local Installation', + 'Checking...', + TreeItemCollapsibleState.None, + 'sync~spin' + ); + } + + const description = installationStatus.is_installed + ? `Installed (v${installationStatus.version})` + : 'Not Installed'; + const icon = installationStatus.is_installed ? 'check' : 'warning'; + + return new EnvironmentItem( + 'ZenML Local Installation', + description, + TreeItemCollapsibleState.None, + icon + ); } /** diff --git a/src/views/activityBar/pipelineView/PipelineDataProvider.ts b/src/views/activityBar/pipelineView/PipelineDataProvider.ts index 66234c31..f707cc91 100644 --- a/src/views/activityBar/pipelineView/PipelineDataProvider.ts +++ b/src/views/activityBar/pipelineView/PipelineDataProvider.ts @@ -21,7 +21,7 @@ import { LSP_ZENML_CLIENT_INITIALIZED, LSP_ZENML_STACK_CHANGED, } from '../../../utils/constants'; -import { ErrorTreeItem, createErrorItem } from '../common/ErrorTreeItem'; +import { ErrorTreeItem, createErrorItem, createAuthErrorItem } from '../common/ErrorTreeItem'; import { LOADING_TREE_ITEMS } from '../common/LoadingTreeItem'; import { PipelineRunTreeItem, PipelineTreeItem } from './PipelineTreeItems'; import { CommandTreeItem } from '../common/PaginationTreeItems'; @@ -45,7 +45,6 @@ export class PipelineDataProvider implements TreeDataProvider { totalPages: 0, }; - constructor() { this.subscribeToEvents(); } @@ -129,12 +128,34 @@ export class PipelineDataProvider implements TreeDataProvider { */ async getChildren(element?: TreeItem): Promise { if (!element) { - const runs = await this.fetchPipelineRuns(this.pagination.currentPage, this.pagination.itemsPerPage); + if (Array.isArray(this.pipelineRuns) && this.pipelineRuns.length > 0) { + return this.pipelineRuns; + } + + // Fetch pipeline runs for the current page and add pagination controls if necessary + const runs = await this.fetchPipelineRuns( + this.pagination.currentPage, + this.pagination.itemsPerPage + ); if (this.pagination.currentPage < this.pagination.totalPages) { - runs.push(new CommandTreeItem("Next Page", 'zenml.nextPipelineRunsPage', undefined, 'arrow-circle-right')); + runs.push( + new CommandTreeItem( + 'Next Page', + 'zenml.nextPipelineRunsPage', + undefined, + 'arrow-circle-right' + ) + ); } if (this.pagination.currentPage > 1) { - runs.unshift(new CommandTreeItem("Previous Page", 'zenml.previousPipelineRunsPage', undefined, 'arrow-circle-left')); + runs.unshift( + new CommandTreeItem( + 'Previous Page', + 'zenml.previousPipelineRunsPage', + undefined, + 'arrow-circle-left' + ) + ); } return runs; } else if (element instanceof PipelineTreeItem) { @@ -153,10 +174,18 @@ export class PipelineDataProvider implements TreeDataProvider { } try { const lsClient = LSClient.getInstance(); - const result = await lsClient.sendLsClientRequest( - 'getPipelineRuns', - [page, itemsPerPage] - ); + const result = await lsClient.sendLsClientRequest('getPipelineRuns', [ + page, + itemsPerPage, + ]); + + if (Array.isArray(result) && result.length === 1 && 'error' in result[0]) { + const errorMessage = result[0].error; + if (errorMessage.includes('Authentication error')) { + return createAuthErrorItem(errorMessage); + } + } + if (!result || 'error' in result) { if ('clientVersion' in result && 'serverVersion' in result) { return createErrorItem(result); @@ -197,7 +226,12 @@ export class PipelineDataProvider implements TreeDataProvider { } } catch (error: any) { console.error(`Failed to fetch stacks: ${error}`); - return [new ErrorTreeItem("Error", `Failed to fetch pipeline runs: ${error.message || error.toString()}`)]; + return [ + new ErrorTreeItem( + 'Error', + `Failed to fetch pipeline runs: ${error.message || error.toString()}` + ), + ]; } } @@ -217,7 +251,7 @@ export class PipelineDataProvider implements TreeDataProvider { public async updateItemsPerPage() { const selected = await window.showQuickPick(ITEMS_PER_PAGE_OPTIONS, { - placeHolder: "Choose the max number of pipeline runs to display per page", + placeHolder: 'Choose the max number of pipeline runs to display per page', }); if (selected) { this.pagination.itemsPerPage = parseInt(selected, 10); diff --git a/src/views/activityBar/serverView/ServerDataProvider.ts b/src/views/activityBar/serverView/ServerDataProvider.ts index 1d1d24b4..0b99d6ea 100644 --- a/src/views/activityBar/serverView/ServerDataProvider.ts +++ b/src/views/activityBar/serverView/ServerDataProvider.ts @@ -10,7 +10,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express // or implied.See the License for the specific language governing // permissions and limitations under the License. -import { EventEmitter, ThemeIcon, TreeDataProvider, TreeItem } from 'vscode'; +import { EventEmitter, ThemeColor, ThemeIcon, TreeDataProvider, TreeItem } from 'vscode'; import { State } from 'vscode-languageclient'; import { checkServerStatus, isServerStatus } from '../../../commands/server/utils'; import { EventBus } from '../../../services/EventBus'; @@ -20,6 +20,7 @@ import { LSCLIENT_STATE_CHANGED, LSP_ZENML_CLIENT_INITIALIZED, REFRESH_SERVER_STATUS, + SERVER_STATUS_UPDATED, } from '../../../utils/constants'; import { LOADING_TREE_ITEMS } from '../common/LoadingTreeItem'; import { ServerTreeItem } from './ServerTreeItems'; @@ -104,7 +105,7 @@ export class ServerDataProvider implements TreeDataProvider { const serverStatus = await checkServerStatus(); if (isServerStatus(serverStatus)) { if (JSON.stringify(serverStatus) !== JSON.stringify(this.currentStatus)) { - this.eventBus.emit('serverStatusUpdated', { + this.eventBus.emit(SERVER_STATUS_UPDATED, { isConnected: serverStatus.isConnected, serverUrl: serverStatus.url, }); @@ -133,7 +134,10 @@ export class ServerDataProvider implements TreeDataProvider { getTreeItem(element: TreeItem): TreeItem { if (element instanceof ServerTreeItem) { if (element.serverStatus.isConnected) { - element.iconPath = new ThemeIcon('vm-active'); + element.iconPath = new ThemeIcon( + 'vm-active', + new ThemeColor('gitDecoration.addedResourceForeground') + ); } else { element.iconPath = new ThemeIcon('vm-connect'); } @@ -150,7 +154,6 @@ export class ServerDataProvider implements TreeDataProvider { async getChildren(element?: TreeItem): Promise { if (!element) { if (isServerStatus(this.currentStatus)) { - console.log(this.currentStatus); const updatedServerTreeItem = new ServerTreeItem('Server Status', this.currentStatus); return [updatedServerTreeItem]; } else if (Array.isArray(this.currentStatus)) { diff --git a/src/views/activityBar/serverView/ServerTreeItems.ts b/src/views/activityBar/serverView/ServerTreeItems.ts index 1c89dd2a..a39d29a2 100644 --- a/src/views/activityBar/serverView/ServerTreeItems.ts +++ b/src/views/activityBar/serverView/ServerTreeItems.ts @@ -10,28 +10,33 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express // or implied.See the License for the specific language governing // permissions and limitations under the License. -import * as vscode from 'vscode'; +import { ThemeColor, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; import { ServerStatus } from '../../../types/ServerInfoTypes'; /** * A specialized TreeItem for displaying details about a server's status. */ -class ServerDetailTreeItem extends vscode.TreeItem { +class ServerDetailTreeItem extends TreeItem { /** * Constructs a new ServerDetailTreeItem instance. * * @param {string} label The detail label. * @param {string} detail The detail value. + * @param {string} [iconName] The name of the icon to display. */ - constructor(label: string, detail: string) { - super(`${label}: ${detail}`, vscode.TreeItemCollapsibleState.None); - // make URL item clickable (if not local db path) + constructor(label: string, detail: string, iconName?: string) { + super(`${label}: ${detail}`, TreeItemCollapsibleState.None); + if (iconName) { + this.iconPath = new ThemeIcon(iconName); + } + if (detail.startsWith('http://') || detail.startsWith('https://')) { this.command = { title: 'Open URL', command: 'vscode.open', - arguments: [vscode.Uri.parse(detail)], + arguments: [Uri.parse(detail)], }; + this.iconPath = new ThemeIcon('link', new ThemeColor('textLink.foreground')); this.tooltip = `Click to open ${detail}`; } } @@ -41,8 +46,8 @@ class ServerDetailTreeItem extends vscode.TreeItem { * TreeItem for representing and visualizing server status in a tree view. Includes details such as connectivity, * host, and port as children items when connected, or storeType and storeUrl when disconnected. */ -export class ServerTreeItem extends vscode.TreeItem { - public children: vscode.TreeItem[] | ServerDetailTreeItem[] | undefined; +export class ServerTreeItem extends TreeItem { + public children: TreeItem[] | ServerDetailTreeItem[] | undefined; constructor( public readonly label: string, @@ -51,8 +56,8 @@ export class ServerTreeItem extends vscode.TreeItem { super( label, serverStatus.isConnected - ? vscode.TreeItemCollapsibleState.Expanded - : vscode.TreeItemCollapsibleState.Expanded + ? TreeItemCollapsibleState.Expanded + : TreeItemCollapsibleState.Expanded ); this.description = `${this.serverStatus.isConnected ? 'Connected ✅' : 'Disconnected'}`; @@ -61,38 +66,44 @@ export class ServerTreeItem extends vscode.TreeItem { private determineChildrenBasedOnStatus(): ServerDetailTreeItem[] { const children: ServerDetailTreeItem[] = [ - new ServerDetailTreeItem('URL', this.serverStatus.url), - new ServerDetailTreeItem('Version', this.serverStatus.version), - new ServerDetailTreeItem('Store Type', this.serverStatus.store_type || 'N/A'), - new ServerDetailTreeItem('Deployment Type', this.serverStatus.deployment_type), - new ServerDetailTreeItem('Database Type', this.serverStatus.database_type), - new ServerDetailTreeItem('Secrets Store Type', this.serverStatus.secrets_store_type), + new ServerDetailTreeItem('URL', this.serverStatus.url, 'link'), + new ServerDetailTreeItem('Version', this.serverStatus.version, 'versions'), + new ServerDetailTreeItem('Store Type', this.serverStatus.store_type || 'N/A', 'database'), + new ServerDetailTreeItem('Deployment Type', this.serverStatus.deployment_type, 'rocket'), + new ServerDetailTreeItem('Database Type', this.serverStatus.database_type, 'database'), + new ServerDetailTreeItem('Secrets Store Type', this.serverStatus.secrets_store_type, 'lock'), ]; // Conditional children based on server status type if (this.serverStatus.id) { - children.push(new ServerDetailTreeItem('ID', this.serverStatus.id)); + children.push(new ServerDetailTreeItem('ID', this.serverStatus.id, 'key')); } if (this.serverStatus.username) { - children.push(new ServerDetailTreeItem('Username', this.serverStatus.username)); + children.push(new ServerDetailTreeItem('Username', this.serverStatus.username, 'account')); } if (this.serverStatus.debug !== undefined) { - children.push(new ServerDetailTreeItem('Debug', this.serverStatus.debug ? 'true' : 'false')); + children.push( + new ServerDetailTreeItem('Debug', this.serverStatus.debug ? 'true' : 'false', 'bug') + ); } if (this.serverStatus.auth_scheme) { - children.push(new ServerDetailTreeItem('Auth Scheme', this.serverStatus.auth_scheme)); + children.push( + new ServerDetailTreeItem('Auth Scheme', this.serverStatus.auth_scheme, 'shield') + ); } // Specific to SQL Server Status if (this.serverStatus.database) { - children.push(new ServerDetailTreeItem('Database', this.serverStatus.database)); + children.push(new ServerDetailTreeItem('Database', this.serverStatus.database, 'database')); } if (this.serverStatus.backup_directory) { children.push( - new ServerDetailTreeItem('Backup Directory', this.serverStatus.backup_directory) + new ServerDetailTreeItem('Backup Directory', this.serverStatus.backup_directory, 'folder') ); } if (this.serverStatus.backup_strategy) { - children.push(new ServerDetailTreeItem('Backup Strategy', this.serverStatus.backup_strategy)); + children.push( + new ServerDetailTreeItem('Backup Strategy', this.serverStatus.backup_strategy, 'shield') + ); } return children; diff --git a/src/views/activityBar/stackView/StackDataProvider.ts b/src/views/activityBar/stackView/StackDataProvider.ts index 51c1327a..9dfe1b76 100644 --- a/src/views/activityBar/stackView/StackDataProvider.ts +++ b/src/views/activityBar/stackView/StackDataProvider.ts @@ -14,14 +14,14 @@ import { Event, EventEmitter, TreeDataProvider, TreeItem, window, workspace } fr import { State } from 'vscode-languageclient'; import { EventBus } from '../../../services/EventBus'; import { LSClient } from '../../../services/LSClient'; -import { Stack, StackComponent, StacksReponse } from '../../../types/StackTypes'; +import { Stack, StackComponent, StacksResponse } from '../../../types/StackTypes'; import { ITEMS_PER_PAGE_OPTIONS, LSCLIENT_STATE_CHANGED, LSP_ZENML_CLIENT_INITIALIZED, LSP_ZENML_STACK_CHANGED, } from '../../../utils/constants'; -import { ErrorTreeItem, createErrorItem } from '../common/ErrorTreeItem'; +import { ErrorTreeItem, createErrorItem, createAuthErrorItem } from '../common/ErrorTreeItem'; import { LOADING_TREE_ITEMS } from '../common/LoadingTreeItem'; import { StackComponentTreeItem, StackTreeItem } from './StackTreeItems'; import { CommandTreeItem } from '../common/PaginationTreeItems'; @@ -34,7 +34,7 @@ export class StackDataProvider implements TreeDataProvider { private static instance: StackDataProvider | null = null; private eventBus = EventBus.getInstance(); private zenmlClientReady = false; - public stacks: Stack[] | TreeItem[] = [LOADING_TREE_ITEMS.get('stacks')!]; + public stacks: StackTreeItem[] | TreeItem[] = [LOADING_TREE_ITEMS.get('stacks')!]; private pagination = { currentPage: 1, @@ -43,7 +43,6 @@ export class StackDataProvider implements TreeDataProvider { totalPages: 0, }; - constructor() { this.subscribeToEvents(); } @@ -105,6 +104,7 @@ export class StackDataProvider implements TreeDataProvider { public async refresh(): Promise { this.stacks = [LOADING_TREE_ITEMS.get('stacks')!]; this._onDidChangeTreeData.fire(undefined); + const page = this.pagination.currentPage; const itemsPerPage = this.pagination.itemsPerPage; @@ -123,17 +123,28 @@ export class StackDataProvider implements TreeDataProvider { * * @returns {Promise} A promise that resolves with an array of `StackTreeItem` objects. */ - async fetchStacksWithComponents(page: number = 1, itemsPerPage: number = 20): Promise { + async fetchStacksWithComponents( + page: number = 1, + itemsPerPage: number = 20 + ): Promise { if (!this.zenmlClientReady) { return [LOADING_TREE_ITEMS.get('zenmlClient')!]; } try { const lsClient = LSClient.getInstance(); - const result = await lsClient.sendLsClientRequest( - 'fetchStacks', - [page, itemsPerPage] - ); + const result = await lsClient.sendLsClientRequest('fetchStacks', [ + page, + itemsPerPage, + ]); + + if (Array.isArray(result) && result.length === 1 && 'error' in result[0]) { + const errorMessage = result[0].error; + if (errorMessage.includes('Authentication error')) { + return createAuthErrorItem(errorMessage); + } + } + if (!result || 'error' in result) { if ('clientVersion' in result && 'serverVersion' in result) { return createErrorItem(result); @@ -162,8 +173,9 @@ export class StackDataProvider implements TreeDataProvider { } } catch (error: any) { console.error(`Failed to fetch stacks: ${error}`); - return [new ErrorTreeItem("Error", `Failed to fetch stacks: ${error.message || error.toString()}`)]; - + return [ + new ErrorTreeItem('Error', `Failed to fetch stacks: ${error.message || error.toString()}`), + ]; } } @@ -183,7 +195,7 @@ export class StackDataProvider implements TreeDataProvider { public async updateItemsPerPage() { const selected = await window.showQuickPick(ITEMS_PER_PAGE_OPTIONS, { - placeHolder: "Choose the max number of stacks to display per page", + placeHolder: 'Choose the max number of stacks to display per page', }); if (selected) { this.pagination.itemsPerPage = parseInt(selected, 10); @@ -200,12 +212,28 @@ export class StackDataProvider implements TreeDataProvider { */ async getChildren(element?: TreeItem): Promise { if (!element) { - const stacks = await this.fetchStacksWithComponents(this.pagination.currentPage, this.pagination.itemsPerPage); + if (Array.isArray(this.stacks) && this.stacks.length > 0) { + return this.stacks; + } + + const stacks = await this.fetchStacksWithComponents( + this.pagination.currentPage, + this.pagination.itemsPerPage + ); if (this.pagination.currentPage < this.pagination.totalPages) { - stacks.push(new CommandTreeItem("Next Page", 'zenml.nextStackPage', undefined, 'arrow-circle-right')); + stacks.push( + new CommandTreeItem('Next Page', 'zenml.nextStackPage', undefined, 'arrow-circle-right') + ); } if (this.pagination.currentPage > 1) { - stacks.unshift(new CommandTreeItem("Previous Page", 'zenml.previousStackPage', undefined, 'arrow-circle-left')); + stacks.unshift( + new CommandTreeItem( + 'Previous Page', + 'zenml.previousStackPage', + undefined, + 'arrow-circle-left' + ) + ); } return stacks; } else if (element instanceof StackTreeItem) { diff --git a/src/views/activityBar/stackView/StackTreeItems.ts b/src/views/activityBar/stackView/StackTreeItems.ts index 909e0405..524e8993 100644 --- a/src/views/activityBar/stackView/StackTreeItems.ts +++ b/src/views/activityBar/stackView/StackTreeItems.ts @@ -33,7 +33,7 @@ export class StackTreeItem extends vscode.TreeItem { this.isActive = isActive || false; if (isActive) { - this.label = `${this.label} 🟢`; + this.iconPath = new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.green')); } } } diff --git a/src/views/statusBar/index.ts b/src/views/statusBar/index.ts index da6e9ae9..c5a34901 100644 --- a/src/views/statusBar/index.ts +++ b/src/views/statusBar/index.ts @@ -10,11 +10,12 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express // or implied.See the License for the specific language governing // permissions and limitations under the License. -import * as vscode from 'vscode'; -import { stackCommands } from '../../commands/stack/cmds'; -import { getActiveStack } from '../../commands/stack/utils'; +import { QuickPickItemKind, StatusBarAlignment, StatusBarItem, commands, window } from 'vscode'; +import { getActiveStack, switchActiveStack } from '../../commands/stack/utils'; import { EventBus } from '../../services/EventBus'; import { LSP_ZENML_STACK_CHANGED, SERVER_STATUS_UPDATED } from '../../utils/constants'; +import { StackDataProvider } from '../activityBar'; +import { ErrorTreeItem } from '../activityBar/common/ErrorTreeItem'; /** * Represents the ZenML extension's status bar. @@ -22,9 +23,10 @@ import { LSP_ZENML_STACK_CHANGED, SERVER_STATUS_UPDATED } from '../../utils/cons */ export default class ZenMLStatusBar { private static instance: ZenMLStatusBar; - private serverStatusItem: vscode.StatusBarItem; - private activeStackItem: vscode.StatusBarItem; - private activeStack: string = 'Loading...'; + private statusBarItem: StatusBarItem; + private serverStatus = { isConnected: false, serverUrl: '' }; + private activeStack: string = '$(loading~spin) Loading...'; + private activeStackId: string = ''; private eventBus = EventBus.getInstance(); /** @@ -33,9 +35,16 @@ export default class ZenMLStatusBar { * and initiates the initial refresh of the status bar state. */ constructor() { - this.serverStatusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); - this.activeStackItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99); + this.statusBarItem = window.createStatusBarItem(StatusBarAlignment.Right, 100); this.subscribeToEvents(); + this.statusBarItem.command = 'zenml/statusBar/switchStack'; + } + + /** + * Registers the commands associated with the ZenMLStatusBar. + */ + public registerCommands(): void { + commands.registerCommand('zenml/statusBar/switchStack', () => this.switchStack()); } /** @@ -46,11 +55,11 @@ export default class ZenMLStatusBar { private subscribeToEvents(): void { this.eventBus.on(LSP_ZENML_STACK_CHANGED, async () => { await this.refreshActiveStack(); - await stackCommands.refreshStackView(); }); this.eventBus.on(SERVER_STATUS_UPDATED, ({ isConnected, serverUrl }) => { - this.updateServerStatusIndicator(isConnected, serverUrl); + this.updateStatusBarItem(isConnected); + this.serverStatus = { isConnected, serverUrl }; }); } @@ -70,34 +79,105 @@ export default class ZenMLStatusBar { /** * Asynchronously refreshes the active stack display in the status bar. * Attempts to retrieve the current active stack name and updates the status bar item accordingly. - * Displays an error message in the status bar if unable to fetch the active stack. */ public async refreshActiveStack(): Promise { + this.statusBarItem.text = `$(loading~spin) Loading...`; + this.statusBarItem.show(); + try { const activeStack = await getActiveStack(); + this.activeStackId = activeStack?.id || ''; this.activeStack = activeStack?.name || 'default'; - this.activeStackItem.text = `${this.activeStack}`; - this.activeStackItem.tooltip = 'Active ZenML stack.'; - this.activeStackItem.show(); } catch (error) { console.error('Failed to fetch active ZenML stack:', error); this.activeStack = 'Error'; } + this.updateStatusBarItem(this.serverStatus.isConnected); } /** - * Updates the server status indicator in the status bar. - * Sets the text, color, and tooltip of the server status item based on the connection status. + * Updates the status bar item with the server status and active stack information. * * @param {boolean} isConnected Whether the server is currently connected. - * @param {string} serverAddress The address of the server, used in the tooltip. + * @param {string} serverUrl The url of the server, used in the tooltip. */ - public updateServerStatusIndicator(isConnected: boolean, serverAddress: string) { - this.serverStatusItem.text = isConnected ? `$(vm-active)` : `$(vm-connect)`; - this.serverStatusItem.color = isConnected ? 'green' : ''; - this.serverStatusItem.tooltip = isConnected - ? `Server running at ${serverAddress}.` - : 'Server not running. Click to refresh status.'; - this.serverStatusItem.show(); + private updateStatusBarItem(isConnected: boolean) { + this.statusBarItem.text = this.activeStack.includes('loading') + ? this.activeStack + : `⛩ ${this.activeStack}`; + const serverStatusText = isConnected ? 'Connected ✅' : 'Disconnected'; + this.statusBarItem.tooltip = `Server Status: ${serverStatusText}\nActive Stack: ${this.activeStack}\n(click to switch stacks)`; + this.statusBarItem.show(); + } + + /** + * Switches the active stack by prompting the user to select a stack from the available options. + * + * @returns {Promise} A promise that resolves when the active stack has been successfully switched. + */ + private async switchStack(): Promise { + const stackDataProvider = StackDataProvider.getInstance(); + const { stacks } = stackDataProvider; + + const containsErrors = stacks.some(stack => stack instanceof ErrorTreeItem); + + if (containsErrors || stacks.length === 0) { + window.showErrorMessage('No stacks available.'); + return; + } + + const activeStack = stacks.find(stack => stack.id === this.activeStackId); + const otherStacks = stacks.filter(stack => stack.id !== this.activeStackId); + + const quickPickItems = [ + { + label: 'Current Active', + kind: QuickPickItemKind.Separator, + }, + { + label: activeStack?.label as string, + id: activeStack?.id, + kind: QuickPickItemKind.Default, + disabled: true, + }, + ...otherStacks.map(stack => ({ + id: stack.id, + label: stack.label as string, + kind: QuickPickItemKind.Default, + })), + ]; + + // Temporarily disable the tooltip to prevent it from appearing after making a selection + this.statusBarItem.tooltip = undefined; + + const selectedStack = await window.showQuickPick(quickPickItems, { + placeHolder: 'Select a stack to switch to', + matchOnDescription: true, + matchOnDetail: true, + ignoreFocusOut: false, + }); + + if (selectedStack && selectedStack.id !== this.activeStackId) { + this.statusBarItem.text = `$(loading~spin) Switching...`; + this.statusBarItem.show(); + + const stackId = otherStacks.find(stack => stack.label === selectedStack.label)?.id; + if (stackId) { + await switchActiveStack(stackId); + await StackDataProvider.getInstance().refresh(); + this.activeStackId = stackId; + this.activeStack = selectedStack.label; + this.statusBarItem.text = `⛩ ${selectedStack.label}`; + } + } + + this.statusBarItem.hide(); + setTimeout(() => { + const serverStatusText = this.serverStatus.isConnected + ? 'Connected ✅' + : 'Disconnected (local)'; + this.statusBarItem.tooltip = `Server Status: ${serverStatusText}\nActive Stack: ${this.activeStack}\n(click to switch stacks)`; + this.statusBarItem.show(); + }, 0); } }