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..eb0625902 100644 --- a/app/src/context_chips/node_version_popup.rs +++ b/app/src/context_chips/node_version_popup.rs @@ -25,10 +25,69 @@ 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_latest_node_button: ViewHandle, + install_nvm_button: ViewHandle, + install_mise_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, versions_menu: Option>>, @@ -39,16 +98,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,33 +138,62 @@ 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_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); + }) + }); + + 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()); + get_menu_selected_index(&versions, current_version.as_deref(), has_nvm && has_mise); menu.set_selected_by_index(selected_index, ctx); menu }); @@ -117,7 +208,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); @@ -125,10 +216,17 @@ impl NodeVersionPopupView { _ => {} }); + let has_both_managers = has_nvm && has_mise; + Self { - install_button, - install_latest_node_button, + install_nvm_button, + install_mise_button, + install_latest_nvm_button, + install_latest_mise_button, has_nvm, + has_mise, + has_both_managers, + active_manager, versions, current_version, versions_menu, @@ -155,7 +253,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 +279,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 +291,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 +305,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) @@ -224,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( @@ -251,11 +368,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, ) @@ -269,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) @@ -278,12 +400,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 +505,57 @@ 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.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 { + 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 { - list_nvm_versions() + self.versions = if self.has_nvm || self.has_mise { + list_versions(self.active_manager) } else { Vec::new() }; @@ -336,11 +563,11 @@ 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 = - 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); }); } @@ -360,10 +587,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 +644,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_menu_selected_index(&self.versions, self.current_version.as_deref(), self.has_both_managers); + 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 +770,46 @@ fn detect_nvm_installed() -> bool { } } -// Enumerate installed Node versions managed by nvm (best-effort, cross-OS) +/// 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 { + // The primary check: is the `mise` executable available in PATH? + if in_path("mise") { + return true; + } + + #[cfg(not(windows))] + { + use std::path::Path; + + // 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; + } + } + } + + 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 +872,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 +954,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)) } @@ -596,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 + } +}