From 64b019178d9d1810cf339aaee2374e19ea09f7e3 Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Sat, 2 May 2026 02:10:57 +0700 Subject: [PATCH 1/2] feat(context-chips): add mise-en-place support for node version switching Add mise as an alternative version manager alongside nvm in the Node.js version switching popup. When mise is detected, the popup lists installed versions from mise's install directories and uses mise-specific commands for switching and installing versions. - Add VersionManager enum (Nvm/Mise) with version-manager-specific commands for switching, installing, and manager installation - Add detect_mise_installed() with cross-OS detection via MISE_DATA_DIR, PATH binary check, ~/.local/share/mise, ~/.mise, and Homebrew paths - Add list_mise_node_versions() with symlink filtering and semantic version validation to avoid listing alias directories like '25', '25.9', 'latest' that mise creates as symlinks - Normalize version strings (strip 'v' prefix) for mise commands since 'mise use node@18.19.1' expects no 'v' prefix unlike 'nvm use v18.19.1' - Update empty state to show both 'Install nvm' and 'Install mise' buttons when no version manager is detected - Add 'Switch to mise/nvm' menu item when both managers are available - Update NodeVersionPopupEvent to carry version-manager-specific command strings instead of hardcoding nvm commands - Update display_chip.rs event handling to use the command strings from the popup events Closes #9715 --- app/src/context_chips/display_chip.rs | 18 +- app/src/context_chips/node_version_popup.rs | 498 +++++++++++++++++--- 2 files changed, 455 insertions(+), 61 deletions(-) diff --git a/app/src/context_chips/display_chip.rs b/app/src/context_chips/display_chip.rs index 72cdec32c..125547d8d 100644 --- a/app/src/context_chips/display_chip.rs +++ b/app/src/context_chips/display_chip.rs @@ -705,10 +705,10 @@ impl DisplayChip { me.close_node_version_popup(ctx); ctx.focus_self(); } - NodeVersionPopupEvent::SelectVersion { version } => { - ctx.emit(PromptDisplayChipEvent::TryExecuteCommand(format!( - "nvm use {version}" - ))); + NodeVersionPopupEvent::SelectVersion { version: _, switch_command } => { + ctx.emit(PromptDisplayChipEvent::TryExecuteCommand( + switch_command.clone(), + )); me.close_node_version_popup(ctx); ctx.focus_self(); } @@ -724,9 +724,15 @@ impl DisplayChip { })); me.close_node_version_popup(ctx); } - NodeVersionPopupEvent::InstallLatestNodeVersion => { + NodeVersionPopupEvent::InstallMise => { + ctx.emit(PromptDisplayChipEvent::RunAgentQuery( + "Install mise-en-place for me".to_string(), + )); + me.close_node_version_popup(ctx); + } + NodeVersionPopupEvent::InstallLatestNodeVersion { command } => { ctx.emit(PromptDisplayChipEvent::TryExecuteCommand( - "nvm install node".to_string(), + command.clone(), )); me.close_node_version_popup(ctx); } diff --git a/app/src/context_chips/node_version_popup.rs b/app/src/context_chips/node_version_popup.rs index 24238bbbd..43d8dd7b9 100644 --- a/app/src/context_chips/node_version_popup.rs +++ b/app/src/context_chips/node_version_popup.rs @@ -25,10 +25,64 @@ use crate::view_components::action_button::{ActionButton, SecondaryTheme}; const MENU_WIDTH: f32 = 300.0; const MENU_MAX_HEIGHT: f32 = 260.0; +/// The version manager used for Node.js version switching. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VersionManager { + Nvm, + Mise, +} + +impl VersionManager { + /// Returns the display name of the version manager. + pub fn display_name(&self) -> &'static str { + match self { + Self::Nvm => "nvm", + Self::Mise => "mise", + } + } + + /// Returns the command to switch to a specific Node.js version. + pub fn switch_command(&self, version: &str) -> String { + match self { + Self::Nvm => format!("nvm use {version}"), + // mise expects versions without a 'v' prefix (e.g., node@18.19.1 not node@v18.19.1) + // --global writes to ~/.config/mise/config.toml; with shell hooks active, this + // takes effect immediately, matching nvm's `nvm use` behavior. + Self::Mise => format!("mise use --global node@{}", normalize_version(version)), + } + } + + /// Returns the command to install the latest Node.js version. + pub fn install_latest_command(&self) -> String { + match self { + Self::Nvm => "nvm install node".to_string(), + Self::Mise => "mise install node@latest".to_string(), + } + } + + /// Returns the agent query to install this version manager. + pub fn install_manager_agent_query(&self) -> String { + match self { + Self::Nvm => { + if cfg!(windows) { + "Uninstall existing Node.js installation and install nvm for me" + .to_string() + } else { + "Install nvm for me".to_string() + } + } + Self::Mise => "Install mise-en-place for me".to_string(), + } + } +} + pub struct NodeVersionPopupView { - install_button: ViewHandle, + install_nvm_button: ViewHandle, + install_mise_button: ViewHandle, install_latest_node_button: ViewHandle, has_nvm: bool, + has_mise: bool, + active_manager: VersionManager, versions: Vec, current_version: Option, versions_menu: Option>>, @@ -39,16 +93,19 @@ pub struct NodeVersionPopupView { pub enum NodeVersionPopupAction { ClosePopup, InstallNvm, + InstallMise, InstallLatestNodeVersion, SelectVersion { version: String }, + SwitchVersionManager { manager: VersionManager }, } #[derive(Debug, Clone)] pub enum NodeVersionPopupEvent { Close, InstallNvm, - InstallLatestNodeVersion, - SelectVersion { version: String }, + InstallMise, + InstallLatestNodeVersion { command: String }, + SelectVersion { version: String, switch_command: String }, } struct Styles { @@ -76,31 +133,55 @@ impl NodeVersionPopupView { model_events: &ModelHandle, ctx: &mut ViewContext, ) -> Self { - let install_button = ctx.add_typed_action_view(|_ctx| { + let has_nvm = detect_nvm_installed(); + let has_mise = detect_mise_installed(); + + // Default to nvm if both are available, mise if only mise is available + let active_manager = if has_nvm { + VersionManager::Nvm + } else if has_mise { + VersionManager::Mise + } else { + VersionManager::Nvm // fallback, doesn't matter since no manager is installed + }; + + let install_nvm_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Install nvm", SecondaryTheme) .with_icon(icons::Icon::Terminal) .on_click(|ctx| { ctx.dispatch_typed_action(NodeVersionPopupAction::InstallNvm); }) }); - let install_latest_node_button = ctx.add_typed_action_view(|_ctx| { - ActionButton::new("nvm install node", SecondaryTheme) + + let install_mise_button = ctx.add_typed_action_view(|_ctx| { + ActionButton::new("Install mise", SecondaryTheme) .with_icon(icons::Icon::Terminal) .on_click(|ctx| { - ctx.dispatch_typed_action(NodeVersionPopupAction::InstallLatestNodeVersion); + ctx.dispatch_typed_action(NodeVersionPopupAction::InstallMise); }) }); - let has_nvm = detect_nvm_installed(); - let versions = if has_nvm { - list_nvm_versions() + + let versions = if has_nvm || has_mise { + list_versions(active_manager) } else { Vec::new() }; - let versions_menu = if has_nvm { + let install_latest_node_button = ctx.add_typed_action_view(|_ctx| { + ActionButton::new( + active_manager.install_latest_command(), + SecondaryTheme, + ) + .with_icon(icons::Icon::Terminal) + .on_click(|ctx| { + ctx.dispatch_typed_action(NodeVersionPopupAction::InstallLatestNodeVersion); + }) + }); + + let versions_menu = if has_nvm || has_mise { let menu_handle = ctx.add_typed_action_view(|ctx| { let mut menu = Menu::new().with_width(MENU_WIDTH); - menu.set_items(Self::menu_items(&versions, current_version.as_deref()), ctx); + menu.set_items(Self::menu_items(&versions, current_version.as_deref(), has_nvm && has_mise, active_manager), ctx); let selected_index = get_selected_version_index(&versions, current_version.as_deref()); menu.set_selected_by_index(selected_index, ctx); @@ -117,7 +198,7 @@ impl NodeVersionPopupView { }; // Subscribe to command execution events to refresh - // when nvm is installed or a node version is installed + // when a version manager is installed or a node version is installed ctx.subscribe_to_model(model_events, |me, _model, event, ctx| match event { ModelEvent::ExecutedInBandCommand(_) | ModelEvent::AfterBlockCompleted(_) => { me.refresh(ctx); @@ -126,9 +207,12 @@ impl NodeVersionPopupView { }); Self { - install_button, + install_nvm_button, + install_mise_button, install_latest_node_button, has_nvm, + has_mise, + active_manager, versions, current_version, versions_menu, @@ -155,7 +239,7 @@ impl NodeVersionPopupView { } } - fn render_install_nvm_empty_state(&self, app: &AppContext) -> Box { + fn render_install_manager_empty_state(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let styles = self.styles(appearance); @@ -181,7 +265,7 @@ impl NodeVersionPopupView { col.add_child( Text::new( - "Install nvm to enable version switching", + "Install a version manager to enable version switching", styles.ui_font_family, styles.detail_font_size + 2., ) @@ -193,7 +277,7 @@ impl NodeVersionPopupView { col.add_child( Container::new( Text::new( - "This menu helps you switch between Node.js versions — but it requires nvm to be installed.", + "This menu helps you switch between Node.js versions — but it requires nvm or mise to be installed.", styles.ui_font_family, styles.detail_font_size, ) @@ -207,7 +291,20 @@ impl NodeVersionPopupView { .finish(), ); - col.add_child(ChildView::new(&self.install_button).finish()); + // Show both install buttons + let mut buttons_row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_alignment(MainAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Min); + + buttons_row.add_child(ChildView::new(&self.install_nvm_button).finish()); + buttons_row.add_child( + Container::new(ChildView::new(&self.install_mise_button).finish()) + .with_margin_left(8.) + .finish(), + ); + + col.add_child(buttons_row.finish()); ConstrainedBox::new(col.finish()) .with_max_width(MENU_WIDTH) @@ -251,11 +348,11 @@ impl NodeVersionPopupView { .finish(), ); - // Subheading + // Subheading — mention the active version manager col.add_child( Container::new( Text::new( - "Try installing versions with nvm", + format!("Try installing versions with {}", self.active_manager.display_name()), styles.ui_font_family, styles.detail_font_size, ) @@ -278,12 +375,90 @@ impl NodeVersionPopupView { .finish() } + fn render_version_manager_toggle(&self, app: &AppContext) -> Option> { + // Only show the toggle when both version managers are available + if !self.has_nvm || !self.has_mise { + return None; + } + + let appearance = Appearance::as_ref(app); + let styles = self.styles(appearance); + + let mut row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_alignment(MainAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Min); + + // nvm tab + let nvm_active = self.active_manager == VersionManager::Nvm; + let nvm_bg = if nvm_active { + Some(appearance.theme().surface_1()) + } else { + None + }; + let nvm_text_color = if nvm_active { + styles.main_text_color + } else { + styles.tertiary_text_color + }; + + let mut nvm_container = Container::new( + Text::new("nvm", styles.ui_font_family, styles.detail_font_size) + .with_color(nvm_text_color) + .with_style(Properties::default().weight(warpui::fonts::Weight::Semibold)) + .finish(), + ) + .with_vertical_padding(4.) + .with_horizontal_padding(12.); + if let Some(bg) = nvm_bg { + nvm_container = nvm_container.with_background(bg); + } + row.add_child(nvm_container.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))).finish()); + + // mise tab + let mise_active = !nvm_active; + let mise_bg = if mise_active { + Some(appearance.theme().surface_1()) + } else { + None + }; + let mise_text_color = if mise_active { + styles.main_text_color + } else { + styles.tertiary_text_color + }; + + let mut mise_container = Container::new( + Text::new("mise", styles.ui_font_family, styles.detail_font_size) + .with_color(mise_text_color) + .with_style(Properties::default().weight(warpui::fonts::Weight::Semibold)) + .finish(), + ) + .with_vertical_padding(4.) + .with_horizontal_padding(12.); + if let Some(bg) = mise_bg { + mise_container = mise_container.with_background(bg); + } + row.add_child(mise_container.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))).finish()); + + Some( + Container::new(row.finish()) + .with_padding_bottom(8.) + .finish(), + ) + } + fn render_node_version_selector(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let styles = self.styles(appearance); let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); + // Version manager toggle (only when both are available) + if let Some(toggle) = self.render_version_manager_toggle(app) { + col.add_child(toggle); + } + col.add_child( Container::new( Text::new("Installed", styles.ui_font_family, styles.detail_font_size) @@ -305,30 +480,56 @@ impl NodeVersionPopupView { fn menu_items( versions: &[String], current_version: Option<&str>, + has_both_managers: bool, + active_manager: VersionManager, ) -> Vec> { - versions - .iter() - .map(|ver| { - let mut fields = MenuItemFields::new(ver).with_on_select_action( - NodeVersionPopupAction::SelectVersion { - version: ver.clone(), - }, - ); - if is_current_version(ver, current_version) { - fields = fields.with_icon(icons::Icon::Check); - } else { - fields = fields.with_indent(); - } - menu::MenuItem::Item(fields) - }) - .collect() + let mut items: Vec> = Vec::new(); + + // If both managers are available, add a switch option at the top + if has_both_managers { + let inactive = match active_manager { + VersionManager::Nvm => VersionManager::Mise, + VersionManager::Mise => VersionManager::Nvm, + }; + let label = format!("Switch to {}", inactive.display_name()); + let fields = MenuItemFields::new(&label) + .with_icon(icons::Icon::Refresh) + .with_on_select_action(NodeVersionPopupAction::SwitchVersionManager { + manager: inactive, + }); + items.push(menu::MenuItem::Item(fields)); + } + + for ver in versions { + let mut fields = MenuItemFields::new(ver).with_on_select_action( + NodeVersionPopupAction::SelectVersion { + version: ver.clone(), + }, + ); + if is_current_version(ver, current_version) { + fields = fields.with_icon(icons::Icon::Check); + } else { + fields = fields.with_indent(); + } + items.push(menu::MenuItem::Item(fields)); + } + + items } pub fn refresh(&mut self, ctx: &mut ViewContext) { self.has_nvm = detect_nvm_installed(); + self.has_mise = detect_mise_installed(); - self.versions = if self.has_nvm { - list_nvm_versions() + // Update active manager if the current one is no longer available + if self.active_manager == VersionManager::Nvm && !self.has_nvm && self.has_mise { + self.active_manager = VersionManager::Mise; + } else if self.active_manager == VersionManager::Mise && !self.has_mise && self.has_nvm { + self.active_manager = VersionManager::Nvm; + } + + self.versions = if self.has_nvm || self.has_mise { + list_versions(self.active_manager) } else { Vec::new() }; @@ -336,7 +537,7 @@ impl NodeVersionPopupView { if let Some(menu) = &self.versions_menu { menu.update(ctx, |menu, ctx| { menu.set_items( - Self::menu_items(&self.versions, self.current_version.as_deref()), + Self::menu_items(&self.versions, self.current_version.as_deref(), self.has_nvm && self.has_mise, self.active_manager), ctx, ); let selected_index = @@ -360,10 +561,10 @@ impl View for NodeVersionPopupView { let content = if !self.versions.is_empty() { self.render_node_version_selector(app) - } else if self.has_nvm { + } else if self.has_nvm || self.has_mise { self.render_install_latest_node_version_empty_state(app) } else { - self.render_install_nvm_empty_state(app) + self.render_install_manager_empty_state(app) }; let scrollable = ClippedScrollable::vertical( @@ -417,36 +618,63 @@ impl TypedActionView for NodeVersionPopupView { match action { NodeVersionPopupAction::ClosePopup => ctx.emit(NodeVersionPopupEvent::Close), NodeVersionPopupAction::InstallNvm => ctx.emit(NodeVersionPopupEvent::InstallNvm), + NodeVersionPopupAction::InstallMise => ctx.emit(NodeVersionPopupEvent::InstallMise), NodeVersionPopupAction::InstallLatestNodeVersion => { - ctx.emit(NodeVersionPopupEvent::InstallLatestNodeVersion) + ctx.emit(NodeVersionPopupEvent::InstallLatestNodeVersion { + command: self.active_manager.install_latest_command(), + }); } NodeVersionPopupAction::SelectVersion { version } => { ctx.emit(NodeVersionPopupEvent::SelectVersion { version: version.clone(), + switch_command: self.active_manager.switch_command(version), }); ctx.emit(NodeVersionPopupEvent::Close); } + NodeVersionPopupAction::SwitchVersionManager { manager } => { + self.active_manager = *manager; + // Refresh version list with the new manager + self.versions = list_versions(self.active_manager); + if let Some(menu) = &self.versions_menu { + menu.update(ctx, |menu, ctx| { + menu.set_items( + Self::menu_items(&self.versions, self.current_version.as_deref(), self.has_nvm && self.has_mise, self.active_manager), + ctx, + ); + let selected_index = + get_selected_version_index(&self.versions, self.current_version.as_deref()); + menu.set_selected_by_index(selected_index, ctx); + }); + } + ctx.notify(); + } } } } -// Cross-OS detection of nvm availability -fn detect_nvm_installed() -> bool { +// --------------------------------------------------------------------------- +// Version manager detection +// --------------------------------------------------------------------------- + +/// Helper: check if an executable exists in PATH +fn in_path(candidate: &str) -> bool { use std::env; - // Helper: check if an executable exists in PATH - fn in_path(candidate: &str) -> bool { - if let Ok(path_var) = env::var("PATH") { - for dir in env::split_paths(&path_var) { - let mut p = dir.clone(); - p.push(candidate); - if p.is_file() { - return true; - } + if let Ok(path_var) = env::var("PATH") { + for dir in env::split_paths(&path_var) { + let mut p = dir.clone(); + p.push(candidate); + if p.is_file() { + return true; } } - false } + false +} + +// Cross-OS detection of nvm availability +fn detect_nvm_installed() -> bool { + use std::env; // 1) Windows nvm-windows #[cfg(windows)] @@ -516,7 +744,78 @@ fn detect_nvm_installed() -> bool { } } -// Enumerate installed Node versions managed by nvm (best-effort, cross-OS) +/// Cross-OS detection of mise-en-place availability +fn detect_mise_installed() -> bool { + use std::env; + use std::path::Path; + + // 1) Check MISE_DATA_DIR env var + if let Ok(mise_data_dir) = env::var("MISE_DATA_DIR") { + let dir = Path::new(&mise_data_dir); + if dir.is_dir() { + return true; + } + } + + // 2) Check for mise binary in PATH + if in_path("mise") { + return true; + } + + #[cfg(not(windows))] + { + if let Some(home) = dirs::home_dir() { + // Default mise data directory: ~/.local/share/mise + if home.join(".local/share/mise").is_dir() { + return true; + } + + // Alternative: ~/.mise (older installations or mise < v2024.x) + if home.join(".mise").is_dir() { + return true; + } + + // Homebrew installations + let brew_paths: &[&str] = &["/opt/homebrew/bin/mise", "/usr/local/bin/mise"]; + for path in brew_paths { + if Path::new(path).is_file() { + return true; + } + } + } + } + + #[cfg(windows)] + { + // Windows: check APPDATA and LOCALAPPDATA + if let Ok(appdata) = env::var("APPDATA") { + if Path::new(&appdata).join("mise").is_dir() { + return true; + } + } + if let Ok(local_appdata) = env::var("LOCALAPPDATA") { + if Path::new(&local_appdata).join("mise").is_dir() { + return true; + } + } + } + + false +} + +// --------------------------------------------------------------------------- +// Version listing +// --------------------------------------------------------------------------- + +/// List installed Node.js versions using the given version manager. +fn list_versions(manager: VersionManager) -> Vec { + match manager { + VersionManager::Nvm => list_nvm_versions(), + VersionManager::Mise => list_mise_node_versions(), + } +} + +/// Enumerate installed Node versions managed by nvm (best-effort, cross-OS) fn list_nvm_versions() -> Vec { use std::env; use std::path::Path; @@ -579,6 +878,81 @@ fn list_nvm_versions() -> Vec { out } +/// Enumerate installed Node versions managed by mise (best-effort, cross-OS) +/// +/// Mise stores installs under `/installs/node//`, but also creates +/// symlinks for major/minor aliases and "latest" (e.g., `25 -> ./25.9.0`, `latest -> ./25.9.0`). +/// We only list real (non-symlink) directories that look like semantic versions +/// (e.g., `25.9.0`), matching how nvm lists only full version directories. +fn list_mise_node_versions() -> Vec { + use std::env; + use std::path::Path; + + let mut out: Vec = Vec::new(); + + let mut candidates: Vec = Vec::new(); + + // Prefer $MISE_DATA_DIR/installs/node + if let Ok(mise_data_dir) = env::var("MISE_DATA_DIR") { + candidates.push(Path::new(&mise_data_dir).join("installs").join("node")); + } + + #[cfg(not(windows))] + { + if let Some(home) = dirs::home_dir() { + // Default: ~/.local/share/mise/installs/node + candidates.push(home.join(".local/share/mise/installs/node")); + // Alternative: ~/.mise/installs/node + candidates.push(home.join(".mise/installs/node")); + } + } + + #[cfg(windows)] + { + // Windows: check APPDATA and LOCALAPPDATA + if let Ok(appdata) = env::var("APPDATA") { + candidates.push(Path::new(&appdata).join("mise").join("installs").join("node")); + } + if let Ok(local_appdata) = env::var("LOCALAPPDATA") { + candidates.push(Path::new(&local_appdata).join("mise").join("installs").join("node")); + } + } + + for base in candidates { + if let Ok(read_dir) = std::fs::read_dir(&base) { + for entry in read_dir.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + + // Skip symlinks — mise creates alias symlinks like `25 -> ./25.9.0`, + // `latest -> ./25.9.0`, etc. We only want real installed version directories. + let is_real_dir = entry + .file_type() + .ok() + .is_some_and(|ft| ft.is_dir() && !ft.is_symlink()); + + if !is_real_dir { + continue; + } + + // Only include entries that look like semantic versions + // (e.g., "25.9.0", "18.19.1") — skip non-version names like "latest" + if is_semantic_version(&name) { + out.push(name); + } + } + } + } + + // Sort descending so the latest version is first + out.sort_by(|a, b| b.cmp(a)); + out.dedup(); + out +} + +// --------------------------------------------------------------------------- +// Version comparison helpers +// --------------------------------------------------------------------------- + fn normalize_version(ver: &str) -> String { ver.trim() .strip_prefix('v') @@ -586,6 +960,20 @@ fn normalize_version(ver: &str) -> String { .to_string() } +/// Checks whether a string looks like a semantic version (e.g., "18.19.1", "25.9.0"). +/// This filters out non-version directory names like "latest" or arbitrary aliases +/// that mise may create as symlinks. +fn is_semantic_version(name: &str) -> bool { + let name = name.trim(); + if name.is_empty() { + return false; + } + // A semantic version consists of dot-separated numeric components + // (e.g., "18", "18.19", "18.19.1"). All parts must be non-empty and numeric. + name.split('.') + .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())) +} + fn is_current_version(candidate: &str, current: Option<&str>) -> bool { current.is_some_and(|cur| normalize_version(candidate) == normalize_version(cur)) } From c35881f1fd2dbfe972695832fd74b2fe896e424b Mon Sep 17 00:00:00 2001 From: Tom X Nguyen Date: Sat, 2 May 2026 02:28:11 +0700 Subject: [PATCH 2/2] fix(context-chips): address review feedback for mise support - Show manager switch toggle in the no-versions empty state so users with versions only in the inactive manager can still switch - Offset set_selected_by_index by 1 when the 'Switch to...' row is prepended, preventing off-by-one selection in the version menu - Require the mise binary in PATH for detect_mise_installed() instead of treating data directories as sufficient; avoids 'command not found' after stale uninstalls - Replace single install_latest_node_button with separate nvm/mise install-latest buttons so the label always matches the active manager --- app/src/context_chips/node_version_popup.rs | 121 +++++++++++--------- 1 file changed, 66 insertions(+), 55 deletions(-) diff --git a/app/src/context_chips/node_version_popup.rs b/app/src/context_chips/node_version_popup.rs index 43d8dd7b9..eb0625902 100644 --- a/app/src/context_chips/node_version_popup.rs +++ b/app/src/context_chips/node_version_popup.rs @@ -79,9 +79,14 @@ impl VersionManager { pub struct NodeVersionPopupView { install_nvm_button: ViewHandle, install_mise_button: ViewHandle, - install_latest_node_button: ViewHandle, + /// Install-latest button for nvm ("nvm install node") + install_latest_nvm_button: ViewHandle, + /// Install-latest button for mise ("mise install node@latest") + install_latest_mise_button: ViewHandle, has_nvm: bool, has_mise: bool, + /// Whether both managers are installed (cached for convenience). + has_both_managers: bool, active_manager: VersionManager, versions: Vec, current_version: Option, @@ -167,11 +172,16 @@ impl NodeVersionPopupView { Vec::new() }; - let install_latest_node_button = ctx.add_typed_action_view(|_ctx| { - ActionButton::new( - active_manager.install_latest_command(), - SecondaryTheme, - ) + let install_latest_nvm_button = ctx.add_typed_action_view(|_ctx| { + ActionButton::new("nvm install node", SecondaryTheme) + .with_icon(icons::Icon::Terminal) + .on_click(|ctx| { + ctx.dispatch_typed_action(NodeVersionPopupAction::InstallLatestNodeVersion); + }) + }); + + let install_latest_mise_button = ctx.add_typed_action_view(|_ctx| { + ActionButton::new("mise install node@latest", SecondaryTheme) .with_icon(icons::Icon::Terminal) .on_click(|ctx| { ctx.dispatch_typed_action(NodeVersionPopupAction::InstallLatestNodeVersion); @@ -183,7 +193,7 @@ impl NodeVersionPopupView { let mut menu = Menu::new().with_width(MENU_WIDTH); menu.set_items(Self::menu_items(&versions, current_version.as_deref(), has_nvm && has_mise, active_manager), ctx); let selected_index = - get_selected_version_index(&versions, current_version.as_deref()); + get_menu_selected_index(&versions, current_version.as_deref(), has_nvm && has_mise); menu.set_selected_by_index(selected_index, ctx); menu }); @@ -206,12 +216,16 @@ impl NodeVersionPopupView { _ => {} }); + let has_both_managers = has_nvm && has_mise; + Self { install_nvm_button, install_mise_button, - install_latest_node_button, + install_latest_nvm_button, + install_latest_mise_button, has_nvm, has_mise, + has_both_managers, active_manager, versions, current_version, @@ -321,6 +335,12 @@ impl NodeVersionPopupView { .with_main_axis_alignment(MainAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max); + // Show manager toggle when both are available, even in empty state + // (Fix: otherwise users with versions only in the other manager can't switch) + if let Some(toggle) = self.render_version_manager_toggle(app) { + col.add_child(toggle); + } + col.add_child( Container::new( ConstrainedBox::new( @@ -366,8 +386,13 @@ impl NodeVersionPopupView { .finish(), ); - // Button - col.add_child(ChildView::new(&self.install_latest_node_button).finish()); + // Button — label is dynamically rendered from active_manager + // Button — always reflects the active manager's install command + let install_latest_button = match self.active_manager { + VersionManager::Nvm => &self.install_latest_nvm_button, + VersionManager::Mise => &self.install_latest_mise_button, + }; + col.add_child(ChildView::new(install_latest_button).finish()); ConstrainedBox::new(col.finish()) .with_max_width(MENU_WIDTH) @@ -520,6 +545,7 @@ impl NodeVersionPopupView { pub fn refresh(&mut self, ctx: &mut ViewContext) { self.has_nvm = detect_nvm_installed(); self.has_mise = detect_mise_installed(); + self.has_both_managers = self.has_nvm && self.has_mise; // Update active manager if the current one is no longer available if self.active_manager == VersionManager::Nvm && !self.has_nvm && self.has_mise { @@ -541,7 +567,7 @@ impl NodeVersionPopupView { ctx, ); let selected_index = - get_selected_version_index(&self.versions, self.current_version.as_deref()); + get_menu_selected_index(&self.versions, self.current_version.as_deref(), self.has_both_managers); menu.set_selected_by_index(selected_index, ctx); }); } @@ -642,7 +668,7 @@ impl TypedActionView for NodeVersionPopupView { ctx, ); let selected_index = - get_selected_version_index(&self.versions, self.current_version.as_deref()); + get_menu_selected_index(&self.versions, self.current_version.as_deref(), self.has_both_managers); menu.set_selected_by_index(selected_index, ctx); }); } @@ -744,57 +770,25 @@ fn detect_nvm_installed() -> bool { } } -/// Cross-OS detection of mise-en-place availability +/// Cross-OS detection of mise-en-place availability. +/// +/// Requires a runnable `mise` binary to be present. Data directories alone are +/// insufficient — after an uninstall or stale config, the binary may be absent while +/// directories remain, and emitting commands would fail with `mise: command not found`. fn detect_mise_installed() -> bool { - use std::env; - use std::path::Path; - - // 1) Check MISE_DATA_DIR env var - if let Ok(mise_data_dir) = env::var("MISE_DATA_DIR") { - let dir = Path::new(&mise_data_dir); - if dir.is_dir() { - return true; - } - } - - // 2) Check for mise binary in PATH + // The primary check: is the `mise` executable available in PATH? if in_path("mise") { return true; } #[cfg(not(windows))] { - if let Some(home) = dirs::home_dir() { - // Default mise data directory: ~/.local/share/mise - if home.join(".local/share/mise").is_dir() { - return true; - } - - // Alternative: ~/.mise (older installations or mise < v2024.x) - if home.join(".mise").is_dir() { - return true; - } - - // Homebrew installations - let brew_paths: &[&str] = &["/opt/homebrew/bin/mise", "/usr/local/bin/mise"]; - for path in brew_paths { - if Path::new(path).is_file() { - return true; - } - } - } - } + use std::path::Path; - #[cfg(windows)] - { - // Windows: check APPDATA and LOCALAPPDATA - if let Ok(appdata) = env::var("APPDATA") { - if Path::new(&appdata).join("mise").is_dir() { - return true; - } - } - if let Ok(local_appdata) = env::var("LOCALAPPDATA") { - if Path::new(&local_appdata).join("mise").is_dir() { + // Also check well-known Homebrew locations that may not be on PATH yet + let brew_paths: &[&str] = &["/opt/homebrew/bin/mise", "/usr/local/bin/mise"]; + for path in brew_paths { + if Path::new(path).is_file() { return true; } } @@ -984,3 +978,20 @@ fn get_selected_version_index(versions: &[String], current_version: Option<&str> .position(|v| is_current_version(v, current_version)) .unwrap_or(0) } + +/// Returns the menu index for the currently active version, accounting for the +/// prepended "Switch to ..." row when both version managers are installed. +fn get_menu_selected_index( + versions: &[String], + current_version: Option<&str>, + has_both_managers: bool, +) -> usize { + let version_index = get_selected_version_index(versions, current_version); + // When both managers are available, the first menu item is "Switch to ...", + // so version items are offset by 1. + if has_both_managers { + version_index + 1 + } else { + version_index + } +}