diff --git a/app/assets/bundled/bootstrap/nu.nu b/app/assets/bundled/bootstrap/nu.nu new file mode 100644 index 000000000..40d3df48b --- /dev/null +++ b/app/assets/bundled/bootstrap/nu.nu @@ -0,0 +1 @@ +#include bundled/bootstrap/nu_body.nu diff --git a/app/assets/bundled/bootstrap/nu_body.nu b/app/assets/bundled/bootstrap/nu_body.nu new file mode 100644 index 000000000..cf6ad79be --- /dev/null +++ b/app/assets/bundled/bootstrap/nu_body.nu @@ -0,0 +1,228 @@ +if ($env.WARP_BOOTSTRAPPED? | default "") == "" { + $env.WARP_USING_WINDOWS_CON_PTY = @@USING_CON_PTY_BOOLEAN@@ + + if ($env.WARP_INITIAL_WORKING_DIR? | default "") != "" { + try { cd $env.WARP_INITIAL_WORKING_DIR } catch { null } + hide-env WARP_INITIAL_WORKING_DIR + } + + if ($env.WARP_PATH_APPEND? | default "") != "" { + let extra_paths = ($env.WARP_PATH_APPEND | split row (char esep) | where {|path| $path != "" }) + if (($env.PATH | describe) | str starts-with "list") { + $env.PATH = ($env.PATH ++ $extra_paths) + } else { + $env.PATH = ([($env.PATH | into string)] ++ $extra_paths | str join (char esep)) + } + hide-env WARP_PATH_APPEND + } + + def warp_path_string [] { + let path = ($env.PATH? | default []) + if (($path | describe) | str starts-with "list") { + $path | str join (char esep) + } else { + $path | into string + } + } + + def warp_command_names_by_type [command_type: string] { + try { + scope commands | where type == $command_type | get name | uniq | str join (char nl) + } catch { "" } + } + + def warp_linux_distribution [] { + let os_release_file = if ("/etc/os-release" | path exists) { + "/etc/os-release" + } else if ("/usr/lib/os-release" | path exists) { + "/usr/lib/os-release" + } else { + "" + } + + if $os_release_file == "" { + "" + } else { + try { + open $os_release_file + | lines + | where {|line| $line | str starts-with "NAME=" } + | first + | str replace -r '^NAME="?(.*?)"?$' '$1' + } catch { "" } + } + } + + def warp_send_json_message [message: record] { + let encoded_message = ($message | to json -r | encode hex) + if ($env.WARP_USING_WINDOWS_CON_PTY? | default false) { + print -n $"\u{1b}]9278;d;($encoded_message)\a" + } else { + print -n $"\u{1b}P$d($encoded_message)\u{1b}\\" + } + } + + def warp_send_reset_grid_osc [] { + if ($env.WARP_USING_WINDOWS_CON_PTY? | default false) { + print -n "\u{1b}]9279\a" + } + } + + def warp_send_generator_output_osc [message: string] { + let hex_encoded_message = ($message | encode hex) + let byte_count = ($hex_encoded_message | str length) + print -n $"\u{1b}]9277;A\a($byte_count);($hex_encoded_message)\u{1b}]9277;B\a" + warp_send_reset_grid_osc + } + + def --env warp_run_generator_command [command_id: string, command_text: string] { + $env._WARP_GENERATOR_COMMAND = "1" + let result = (try { ^$nu.current-exe -c $command_text | complete } catch { { stdout: "", stderr: ($in | into string), exit_code: 1 } }) + let raw_output = ([$result.stdout $result.stderr] | where {|part| ($part | into string) != "" } | str join (char nl)) + warp_send_generator_output_osc $"($command_id);($raw_output);($result.exit_code)" + } + + def warp_preexec [] { + let command_text = (try { commandline } catch { "" }) + warp_send_json_message { hook: "Preexec", value: { command: $command_text } } + warp_send_reset_grid_osc + } + + def --env warp_precmd [] { + if ($env._WARP_SUPPRESS_NEXT_PRECMD? | default "") != "" { + hide-env _WARP_SUPPRESS_NEXT_PRECMD + return + } + + let exit_code = ($env.LAST_EXIT_CODE? | default 0) + let next_block_id = $"precmd-($env.WARP_SESSION_ID)-(random int 0..2147483647)" + warp_send_json_message { hook: "CommandFinished", value: { exit_code: $exit_code, next_block_id: $next_block_id } } + warp_send_reset_grid_osc + + if ($env._WARP_GENERATOR_COMMAND? | default "") != "" { + hide-env _WARP_GENERATOR_COMMAND + warp_send_json_message { hook: "Precmd", value: { pwd: "", ps1: "", git_head: "", git_branch: "", virtual_env: "", conda_env: "", node_version: "", session_id: ($env.WARP_SESSION_ID | into int), is_after_in_band_command: true } } + return + } + + let git_branch = (try { ^git symbolic-ref --short HEAD err> /dev/null | str trim } catch { "" }) + let git_head = if $git_branch != "" { $git_branch } else { try { ^git rev-parse --short HEAD err> /dev/null | str trim } catch { "" } } + let honor_ps1 = (($env.WARP_HONOR_PS1? | default "0") == "1") + warp_send_json_message { hook: "Precmd", value: { pwd: (pwd), ps1: "", honor_ps1: $honor_ps1, rprompt: "", git_head: $git_head, git_branch: $git_branch, virtual_env: ($env.VIRTUAL_ENV? | default ""), conda_env: ($env.CONDA_DEFAULT_ENV? | default ""), node_version: "", kube_config: ($env.KUBECONFIG? | default ""), session_id: ($env.WARP_SESSION_ID | into int) } } + } + + def warp_report_input [] { + let input_buffer = (try { commandline } catch { "" }) + warp_send_json_message { hook: "InputBuffer", value: { buffer: $input_buffer } } + try { commandline edit "" } catch { null } + } + + def warp_finish_update [update_id: string] { + warp_send_json_message { hook: "FinishUpdate", value: { update_id: $update_id } } + } + + def warp_handle_dist_upgrade [source_file_name: string] { + let apt_config = (try { which apt-config | get path | first } catch { "" }) + if $apt_config == "" { return } + let apt_sources_dir = (try { ^sh -c $"eval $\((^($apt_config) shell APT_SOURCESDIR 'Dir::Etc::sourceparts/d')\); printf %s $APT_SOURCESDIR" } catch { "" }) + if $apt_sources_dir == "" { return } + let source_file_path = $"($apt_sources_dir)($source_file_name)" + if not ($"($source_file_path).list" | path exists) and not ($"($source_file_path).sources" | path exists) and ($"($source_file_path).list.distUpgrade" | path exists) { + print $"Executing: sudo cp \"($source_file_path).list.distUpgrade\" \"($source_file_path).list\"" + sudo cp $"($source_file_path).list.distUpgrade" $"($source_file_path).list" + } + } + + def clear [] { + warp_send_json_message { hook: "Clear", value: {} } + } + + def --env warp_change_prompt_modes_to_ps1 [] { + $env.WARP_HONOR_PS1 = "1" + warp_set_prompt_indicators + } + + def --env warp_change_prompt_modes_to_warp_prompt [] { + $env.WARP_HONOR_PS1 = "0" + warp_set_prompt_indicators + } + + def warp_bootstrapped [] { + let history_format = ($env.config.history.file_format? | default "plaintext") + let histfile = if $history_format == "plaintext" { $nu.history-path } else { "" } + let alias_lines = (try { scope aliases | each {|alias| $"($alias.name)\t($alias.expansion? | default "")" } | str join (char nl) } catch { "" }) + let env_var_names = (try { $env | columns | str join (char nl) } catch { "" }) + let os_name = ($nu.os-info.name? | default "") + let os_category = if $os_name == "macos" { "MacOS" } else if $os_name == "linux" { "Linux" } else if $os_name == "windows" { "Windows" } else { "" } + let linux_distribution = if $os_category == "Linux" { warp_linux_distribution } else { "" } + let vi_mode_enabled = if (($env.config.edit_mode? | default "") == "vi") { "1" } else { "" } + warp_send_json_message { hook: "Bootstrapped", value: { histfile: $histfile, shell: "nu", home_dir: ($nu.home-path? | default ($env.HOME? | default "")), path: (warp_path_string), editor: ($env.EDITOR? | default ""), abbreviations: "", aliases: $alias_lines, function_names: (warp_command_names_by_type "custom"), env_var_names: $env_var_names, builtins: (warp_command_names_by_type "built-in"), keywords: (warp_command_names_by_type "keyword"), shell_version: (version | get version), shell_options: "", rcfiles_start_time: "", rcfiles_end_time: "", shell_plugins: "", vi_mode_enabled: $vi_mode_enabled, os_category: $os_category, linux_distribution: $linux_distribution, wsl_name: ($env.WSL_DISTRO_NAME? | default ""), shell_path: $nu.current-exe } } + } + + let warp_original_prompt_command = ($env.PROMPT_COMMAND? | default null) + let warp_original_prompt_command_right = ($env.PROMPT_COMMAND_RIGHT? | default null) + $env.WARP_ORIGINAL_PROMPT_INDICATOR = ($env.PROMPT_INDICATOR? | default "> ") + $env.WARP_ORIGINAL_PROMPT_INDICATOR_VI_INSERT = ($env.PROMPT_INDICATOR_VI_INSERT? | default ": ") + $env.WARP_ORIGINAL_PROMPT_INDICATOR_VI_NORMAL = ($env.PROMPT_INDICATOR_VI_NORMAL? | default "> ") + $env.WARP_ORIGINAL_PROMPT_MULTILINE_INDICATOR = ($env.PROMPT_MULTILINE_INDICATOR? | default "::: ") + + def --env warp_set_prompt_indicators [] { + if (($env.WARP_HONOR_PS1? | default "0") == "1") { + $env.PROMPT_INDICATOR = ($env.WARP_ORIGINAL_PROMPT_INDICATOR? | default "> ") + $env.PROMPT_INDICATOR_VI_INSERT = ($env.WARP_ORIGINAL_PROMPT_INDICATOR_VI_INSERT? | default ": ") + $env.PROMPT_INDICATOR_VI_NORMAL = ($env.WARP_ORIGINAL_PROMPT_INDICATOR_VI_NORMAL? | default "> ") + $env.PROMPT_MULTILINE_INDICATOR = ($env.WARP_ORIGINAL_PROMPT_MULTILINE_INDICATOR? | default "::: ") + } else { + $env.PROMPT_INDICATOR = "" + $env.PROMPT_INDICATOR_VI_INSERT = "" + $env.PROMPT_INDICATOR_VI_NORMAL = "" + $env.PROMPT_MULTILINE_INDICATOR = "" + } + } + + $env.PROMPT_COMMAND = {|| + let prompt = if (($env.WARP_HONOR_PS1? | default "0") == "1") { + if (($warp_original_prompt_command | describe) == "closure") { + do $warp_original_prompt_command + } else if $warp_original_prompt_command == null { + "> " + } else { + $warp_original_prompt_command | into string + } + } else { "" } + $"\u{1b}]133;A\a($prompt)\u{1b}]133;B\a" + } + + $env.PROMPT_COMMAND_RIGHT = {|| + let prompt = if (($env.WARP_HONOR_PS1? | default "0") == "1") { + if (($warp_original_prompt_command_right | describe) == "closure") { + do $warp_original_prompt_command_right + } else if $warp_original_prompt_command_right == null { + "" + } else { + $warp_original_prompt_command_right | into string + } + } else { "" } + if $prompt == "" { "" } else { $"\u{1b}]133;P;k=r\a($prompt)\u{1b}]133;B\a" } + } + + $env.config = ( + $env.config + | upsert shell_integration.osc133 false + | upsert shell_integration.osc633 false + | upsert hooks.pre_execution ([{|| warp_preexec }] ++ ($env.config.hooks.pre_execution? | default [])) + | upsert hooks.pre_prompt ([{|| warp_precmd }] ++ ($env.config.hooks.pre_prompt? | default [])) + | upsert keybindings (($env.config.keybindings? | default []) ++ [ + { name: warp_clear_commandline, modifier: control, keycode: char_p, mode: [emacs vi_normal vi_insert], event: { edit: Clear } } + { name: warp_report_input, modifier: alt, keycode: char_i, mode: [emacs vi_normal vi_insert], event: { send: ExecuteHostCommand, cmd: "warp_report_input" } } + { name: warp_prompt_ps1, modifier: alt, keycode: char_p, mode: [emacs vi_normal vi_insert], event: { send: ExecuteHostCommand, cmd: "warp_change_prompt_modes_to_ps1" } } + { name: warp_prompt_warp, modifier: alt, keycode: char_w, mode: [emacs vi_normal vi_insert], event: { send: ExecuteHostCommand, cmd: "warp_change_prompt_modes_to_warp_prompt" } } + ]) + ) + + warp_set_prompt_indicators + warp_precmd + warp_bootstrapped + $env.WARP_BOOTSTRAPPED = "1" + $env._WARP_SUPPRESS_NEXT_PRECMD = "1" +} diff --git a/app/assets/bundled/bootstrap/nu_init_shell.nu b/app/assets/bundled/bootstrap/nu_init_shell.nu new file mode 100644 index 000000000..db04c1e29 --- /dev/null +++ b/app/assets/bundled/bootstrap/nu_init_shell.nu @@ -0,0 +1,6 @@ +$env.WARP_SESSION_ID = ((date now | format date "%s") + (random int 0..32767 | into string)) +let username = ($env.USER? | default ($env.USERNAME? | default "")) +let hostname = (try { ^hostname | str trim } catch { $nu.hostname? | default "" }) +let msg = ({ hook: "InitShell", value: { session_id: ($env.WARP_SESSION_ID | into int), shell: "nu", user: $username, hostname: $hostname } } | to json -r | encode hex) +let using_windows_con_pty = @@USING_CON_PTY_BOOLEAN@@ +if $using_windows_con_pty { print -n $"\u{1b}]9278;d;($msg)\a" } else { print -n $"\u{1b}P$d($msg)\u{1b}\\" } diff --git a/app/src/ai/blocklist/action_model/execute/shell_command.rs b/app/src/ai/blocklist/action_model/execute/shell_command.rs index ac7bd0aaf..ed1a2d35f 100644 --- a/app/src/ai/blocklist/action_model/execute/shell_command.rs +++ b/app/src/ai/blocklist/action_model/execute/shell_command.rs @@ -199,6 +199,9 @@ impl ShellCommandExecutor { // Fish doesn't have grouping characters. We need to use begin; and end; to ensure the command // gets evaluated first. Some(ShellType::Fish) => format!("begin; {command} ;end | command cat"), + // Nushell does not have an equivalent `command cat` pager bypass; group the command + // so it still runs as a single expression. + Some(ShellType::Nu) => format!("({command})"), // For powershell, we use Out-Host to send paged output to the // console. Add a backslash to avoid executing an alias. Some(ShellType::PowerShell) => format!("({command}) | \\Out-Host"), diff --git a/app/src/autoupdate/linux.rs b/app/src/autoupdate/linux.rs index 988925f61..555f59ece 100644 --- a/app/src/autoupdate/linux.rs +++ b/app/src/autoupdate/linux.rs @@ -352,6 +352,10 @@ pub enum PackageManager { impl PackageManager { pub fn update_command(&self, shell_type: ShellType, update_id: &str) -> String { + if shell_type == ShellType::Nu { + return self.nu_update_command(update_id); + } + let package_name = Self::package_name(); let repo_name = Self::repo_name(); let and = shell_type.and_combiner(); @@ -362,7 +366,7 @@ impl PackageManager { distribution_update_disabled_repository, } => { let dist_upgrade_fn = match shell_type { - ShellType::Zsh | ShellType::Bash | ShellType::Fish => { + ShellType::Zsh | ShellType::Bash | ShellType::Fish | ShellType::Nu => { "warp_handle_dist_upgrade" } ShellType::PowerShell => "Warp-Handle-DistUpgrade", @@ -414,12 +418,65 @@ impl PackageManager { }; let finish_update_fn = match shell_type { - ShellType::Zsh | ShellType::Bash | ShellType::Fish => "warp_finish_update", + ShellType::Zsh | ShellType::Bash | ShellType::Fish | ShellType::Nu => { + "warp_finish_update" + } ShellType::PowerShell => "Warp-Finish-Update", }; format!("{base_command}{and}{finish_update_fn} {update_id}") } + fn nu_update_command(&self, update_id: &str) -> String { + let package_name = Self::package_name(); + let repo_name = Self::repo_name(); + + let base_command = match self { + PackageManager::Apt { + distribution_update_disabled_repository, + } => { + let dist_upgrade_prefix = if *distribution_update_disabled_repository { + format!("try {{ warp_handle_dist_upgrade {repo_name} }} catch {{ null }}; ") + } else { + String::new() + }; + format!("{dist_upgrade_prefix}sudo apt update; sudo apt install {package_name}") + } + PackageManager::Yum => { + format!("sudo yum --refresh --repo {repo_name} upgrade {package_name}") + } + PackageManager::Dnf => { + format!("sudo dnf --refresh --repo {repo_name} upgrade {package_name}") + } + PackageManager::Zypper => { + format!("sudo zypper update {package_name}") + } + PackageManager::Pacman { + is_repo_configured, + is_signing_key_configured, + } => { + let repo_prefix = if !is_repo_configured { + let cache_dir = warp_core::paths::cache_dir(); + let cache_dir_str = cache_dir.display(); + // Back up the existing pacman.conf file just in case anything goes wrong, then + // add the repository config. + format!("^mkdir -p {cache_dir_str}; ^cp /etc/pacman.conf {cache_dir_str}; sudo sh -c \"echo '\n[{repo_name}]\nServer = https://releases.warp.dev/linux/pacman/\\$repo/\\$arch' >> /etc/pacman.conf\"; ") + } else { + String::new() + }; + let key_prefix = if !is_signing_key_configured { + // Retrieve our key from keys.openpgp.org and locally sign it before retrieving + // the package repository and installing the updated package. + "sudo pacman-key -r \"linux-maintainers@warp.dev\" --keyserver hkp://keys.openpgp.org:80; sudo pacman-key --lsign-key \"linux-maintainers@warp.dev\"; ".to_string() + } else { + String::new() + }; + format!("{key_prefix}{repo_prefix}sudo pacman -Sy {package_name}") + } + }; + + format!("try {{ {base_command}; warp_finish_update {update_id} }} catch {{ print $in }}") + } + fn package_name() -> &'static str { package_name(ChannelState::channel()) } diff --git a/app/src/autoupdate/linux_test.rs b/app/src/autoupdate/linux_test.rs index 9a89cc7f9..d9fc2ffa9 100644 --- a/app/src/autoupdate/linux_test.rs +++ b/app/src/autoupdate/linux_test.rs @@ -5,3 +5,28 @@ fn test_repo_name() { assert_eq!(repo_name(Channel::Dev), "warpdotdev-dev"); assert_eq!(repo_name(Channel::Stable), "warpdotdev"); } + +#[test] +fn test_nu_update_command_gates_finish_update_on_success() { + let command = PackageManager::Apt { + distribution_update_disabled_repository: false, + } + .update_command(ShellType::Nu, "update-123"); + + assert!(command.starts_with("try { ")); + assert!(command.contains("sudo apt update; sudo apt install ")); + assert!(command.contains("; warp_finish_update update-123 }")); + assert!(!command.contains(" && ")); +} + +#[test] +fn test_nu_update_command_uses_nu_dist_upgrade_handler() { + let command = PackageManager::Apt { + distribution_update_disabled_repository: true, + } + .update_command(ShellType::Nu, "update-123"); + + assert!(command.contains("try { warp_handle_dist_upgrade ")); + assert!(command.contains(" } catch { null }; sudo apt update")); + assert!(command.contains("; warp_finish_update update-123 }")); +} diff --git a/app/src/drive/export.rs b/app/src/drive/export.rs index 62d5223f8..dd06b0437 100644 --- a/app/src/drive/export.rs +++ b/app/src/drive/export.rs @@ -14,7 +14,6 @@ use aho_corasick::{AhoCorasick, MatchKind}; use anyhow::{anyhow, Context}; #[cfg(feature = "local_fs")] use futures::AsyncWriteExt; -use warp_util::path::ShellFamily; use warpui::{ platform::{file_picker::FilePickerError, FilePickerConfiguration, OperatingSystem}, r#async::SpawnedFutureHandle, @@ -24,6 +23,7 @@ use warpui::{ use crate::{ cloud_object::{model::persistence::CloudModel, Space}, safe_warn, + terminal::shell::ShellType, view_components::DismissibleToast, workspace::{active_terminal_in_window, ToastStack}, }; @@ -100,9 +100,11 @@ impl ExportManager { objects: &[CloudObjectTypeAndId], ctx: &mut ModelContext, ) { - let shell_family = - active_terminal_in_window(window_id, ctx, |terminal, ctx| terminal.shell_family(ctx)) - .unwrap_or_else(|| OperatingSystem::get().default_shell_family()); + let shell_type = active_terminal_in_window(window_id, ctx, |terminal, ctx| { + terminal.active_session_shell_type(ctx) + }) + .flatten() + .unwrap_or_else(|| OperatingSystem::get().default_shell_family().into()); let is_bulk = objects.len() > 1; let mut ids = Vec::new(); for object in objects { @@ -128,7 +130,7 @@ impl ExportManager { ctx.open_file_picker( move |result, app| { Self::handle(app).update(app, |me, ctx| { - me.handle_files_picked(ids, result, shell_family, ctx); + me.handle_files_picked(ids, result, shell_type, ctx); }); }, FilePickerConfiguration::new().folders_only(), @@ -140,7 +142,7 @@ impl ExportManager { &mut self, ids: Vec, result: Result, FilePickerError>, - shell_family: ShellFamily, + shell_type: ShellType, ctx: &mut ModelContext, ) { match result { @@ -149,7 +151,7 @@ impl ExportManager { Some(path) => { let path = PathBuf::from(path); for id in ids { - self.run_export(id, &path, shell_family, ctx); + self.run_export(id, &path, shell_type, ctx); } } None => { @@ -180,15 +182,14 @@ impl ExportManager { &mut self, id: ExportId, path: &Path, - shell_family: ShellFamily, + shell_type: ShellType, ctx: &mut ModelContext, ) { match self.exports.entry(id) { Entry::Occupied(mut export) => match export.get().state { State::ChoosingLocation => { log::debug!("Exporting {id:?} to {}", path.display()); - match Self::export_one(id, export.get().is_bulk, path, id.0, shell_family, ctx) - { + match Self::export_one(id, export.get().is_bulk, path, id.0, shell_type, ctx) { Ok(handle) => { export.get_mut().state = State::Exporting(handle); } @@ -266,7 +267,7 @@ impl ExportManager { is_bulk: bool, parent_path: &Path, object: CloudObjectTypeAndId, - shell_family: ShellFamily, + shell_type: ShellType, ctx: &mut ModelContext, ) -> anyhow::Result { let cloud_model = CloudModel::as_ref(ctx); @@ -300,7 +301,7 @@ impl ExportManager { let exported_variables = env_var_collection_model .string_model - .export_variables("\n", shell_family) + .export_variables("\n", shell_type) .into_bytes(); ( diff --git a/app/src/drive/export_tests.rs b/app/src/drive/export_tests.rs index c2501d4a2..8264e7d9c 100644 --- a/app/src/drive/export_tests.rs +++ b/app/src/drive/export_tests.rs @@ -8,7 +8,6 @@ use std::{ use futures::channel::oneshot; use parking_lot::Mutex; use tempfile::TempDir; -use warp_util::path::ShellFamily; use warpui::{AddSingletonModel, App, SingletonEntity, WindowId}; use crate::{ @@ -19,6 +18,7 @@ use crate::{ drive::CloudObjectTypeAndId, notebooks::{CloudNotebook, CloudNotebookModel, NotebookId}, server::ids::SyncId, + terminal::shell::ShellType, workflows::{workflow::Workflow, CloudWorkflow, CloudWorkflowModel, WorkflowId}, workspace::ToastStack, workspaces::user_workspaces::UserWorkspaces, @@ -81,7 +81,7 @@ impl ExportTest { .to_str() .expect("Path must be UTF-8") .to_owned()]), - ShellFamily::Posix, + ShellType::Bash, ctx, ); id @@ -477,7 +477,7 @@ fn test_export_multiple_objects() { .to_str() .expect("Path must be UTF-8") .to_owned()]), - ShellFamily::Posix, + ShellType::Bash, ctx, ); }); diff --git a/app/src/drive/index.rs b/app/src/drive/index.rs index 0025d0900..46d2d9631 100644 --- a/app/src/drive/index.rs +++ b/app/src/drive/index.rs @@ -5292,11 +5292,12 @@ impl TypedActionView for DriveIndex { ctx ); - let shell_family = + let shell_type = active_terminal_in_window(ctx.window_id(), ctx, |terminal, ctx| { - terminal.shell_family(ctx) + terminal.active_session_shell_type(ctx) }) - .unwrap_or_else(|| OperatingSystem::get().default_shell_family()); + .flatten() + .unwrap_or_else(|| OperatingSystem::get().default_shell_family().into()); let cloud_model = CloudModel::as_ref(ctx); let object = cloud_model.get_by_uid(&cloud_object_type_and_id.uid()); @@ -5318,7 +5319,7 @@ impl TypedActionView for DriveIndex { let vars = env_var_collection .model() .string_model - .export_variables(" ", shell_family); + .export_variables(" ", shell_type); ctx.clipboard().write(ClipboardContent::plain_text(vars)); } } diff --git a/app/src/env_vars/mod.rs b/app/src/env_vars/mod.rs index b8e14379f..e148413a8 100644 --- a/app/src/env_vars/mod.rs +++ b/app/src/env_vars/mod.rs @@ -81,36 +81,73 @@ impl EnvVar { pub fn get_initialization_string(&self, shell_type: ShellType) -> String { let shell_family = ShellFamily::from(shell_type); - let name = shell_family.escape(&self.name); - let value = get_init_command_for_env_var(&self.value, shell_family); + let value = get_init_command_for_env_var(&self.value, shell_type, shell_family); match shell_type { ShellType::Bash | ShellType::Zsh => { + let name = shell_family.escape(&self.name); format!("export {name}={value};") } ShellType::Fish => { + let name = shell_family.escape(&self.name); format!("set -x {name} {value};") } + ShellType::Nu => { + let name = format_nu_env_var_name(&self.name); + format!("$env.{name} = {value};") + } ShellType::PowerShell => { + let name = shell_family.escape(&self.name); format!("$env:{name} = {value};") } } } } -fn get_init_command_for_env_var(value: &EnvVarValue, shell_family: ShellFamily) -> String { - match value { - EnvVarValue::Constant(val) => match shell_family { +fn get_init_command_for_env_var( + value: &EnvVarValue, + shell_type: ShellType, + shell_family: ShellFamily, +) -> String { + match (shell_type, value) { + (ShellType::Nu, EnvVarValue::Constant(val)) => { + serde_json::to_string(val).expect("string serialization should never fail") + } + (ShellType::Nu, EnvVarValue::Command(cmd)) => format!("({})", cmd.command), + (ShellType::Nu, EnvVarValue::Secret(secret)) => format!( + "({})", + secret.get_secret_extraction_command(ShellFamily::PowerShell) + ), + (_, EnvVarValue::Constant(val)) => match shell_family { ShellFamily::Posix => shell_family.escape(val).into_owned(), ShellFamily::PowerShell => format!("'{}'", val.replace("'", "''")), }, - EnvVarValue::Command(cmd) => format!("$({})", cmd.command), - EnvVarValue::Secret(secret) => { + (_, EnvVarValue::Command(cmd)) => format!("$({})", cmd.command), + (_, EnvVarValue::Secret(secret)) => { format!("$({})", secret.get_secret_extraction_command(shell_family)) } } } +fn format_nu_env_var_name(name: &str) -> String { + if is_valid_nu_identifier(name) { + name.to_owned() + } else { + serde_json::to_string(name).expect("string serialization should never fail") + } +} + +fn is_valid_nu_identifier(name: &str) -> bool { + let Some(first_char) = name.chars().next() else { + return false; + }; + + (first_char.is_ascii_alphabetic() || first_char == '_') + && name + .chars() + .all(|char| char.is_ascii_alphanumeric() || char == '_') +} + /// Defines the data model for a cloud synced collection of environment variables. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] pub struct EnvVarCollection { @@ -136,8 +173,20 @@ impl EnvVarCollection { self.vars.iter().map(|var| (var.name.as_str(), &var.value)) } - pub fn export_variables(&self, delimeter: &str, shell_family: ShellFamily) -> String { - serialize_variables_internal(self.key_value_iter(), "", "=", "", delimeter, shell_family) + pub fn export_variables(&self, delimeter: &str, shell_type: ShellType) -> String { + if shell_type == ShellType::Nu { + return serialize_nu_variables_internal(self.key_value_iter(), delimeter); + } + + serialize_variables_internal( + self.key_value_iter(), + "", + "=", + "", + delimeter, + shell_type, + ShellFamily::from(shell_type), + ) } pub fn export_variables_for_shell(&self, shell_type: ShellType) -> String { @@ -249,18 +298,46 @@ pub fn serialize_variables_for_shell<'s, I: IntoIterator String { match shell_type { // Warp doesn't support newlines in fish so we can't use env syntax - ShellType::Fish => { - serialize_variables_internal(pairs, "set -x ", " ", ";", " ", shell_type.into()) - } + ShellType::Fish => serialize_variables_internal( + pairs, + "set -x ", + " ", + ";", + " ", + shell_type, + shell_type.into(), + ), ShellType::Bash | ShellType::Zsh => { - serialize_variables_internal(pairs, "", "=", "", " ", shell_type.into()) - } - ShellType::PowerShell => { - serialize_variables_internal(pairs, "$env:", " = ", ";", " ", shell_type.into()) + serialize_variables_internal(pairs, "", "=", "", " ", shell_type, shell_type.into()) } + ShellType::Nu => serialize_nu_variables_internal(pairs, " "), + ShellType::PowerShell => serialize_variables_internal( + pairs, + "$env:", + " = ", + ";", + " ", + shell_type, + shell_type.into(), + ), } } +fn serialize_nu_variables_internal<'s, I: IntoIterator>( + pairs: I, + delimeter: &str, +) -> String { + pairs + .into_iter() + .map(|(name, value)| { + let name = format_nu_env_var_name(name); + let value = get_init_command_for_env_var(value, ShellType::Nu, ShellFamily::PowerShell); + format!("$env.{name} = {value};") + }) + .collect_vec() + .join(delimeter) +} + // Prefix — what's prepended to each variable // Separator — what separates the variable name from the value // Postfix — what's appended to the end of each variable @@ -275,6 +352,7 @@ fn serialize_variables_internal<'s, I: IntoIterator String { pairs @@ -285,10 +363,72 @@ fn serialize_variables_internal<'s, I: IntoIterator (DirectShellStarter, String) { .stdout; String::from_utf8_lossy(&stdout).into_owned() } + shell::ShellType::Nu => { + let stdout = Command::new(starter.logical_shell_path()) + .args(["-c", "$nu.version"]) + .output() + .expect("version command should run") + .stdout; + String::from_utf8_lossy(&stdout).into_owned() + } shell::ShellType::PowerShell => { let stdout = Command::new(starter.logical_shell_path()) .args(["-Version"]) @@ -86,6 +94,7 @@ pub fn current_shell_starter_and_version() -> (DirectShellStarter, String) { pub fn default_histfile_directory(shell: &ShellType, home_dir: &Path) -> PathBuf { match shell { ShellType::Fish => home_dir.join(".local/share/fish"), + ShellType::Nu => home_dir.join(".config/nushell"), #[cfg(not(windows))] ShellType::PowerShell => home_dir.join(".local/share/powershell/PSReadLine"), #[cfg(windows)] diff --git a/app/src/pane_group/pane/local_harness_launch.rs b/app/src/pane_group/pane/local_harness_launch.rs index b65844aa1..6e0a136e2 100644 --- a/app/src/pane_group/pane/local_harness_launch.rs +++ b/app/src/pane_group/pane/local_harness_launch.rs @@ -31,6 +31,10 @@ pub(super) fn normalize_local_child_harness(harness_type: &str) -> Option) -> Result<(), String> { match shell_type { Some(ShellType::Bash) | Some(ShellType::Zsh) | Some(ShellType::Fish) => Ok(()), + Some(ShellType::Nu) => Err( + "Local child harnesses currently require bash, zsh, or fish; Nushell is not supported." + .to_string(), + ), Some(ShellType::PowerShell) => Err( "Local child harnesses currently require bash, zsh, or fish; PowerShell is not supported." .to_string(), diff --git a/app/src/terminal/available_shells.rs b/app/src/terminal/available_shells.rs index 3474b6209..cd79b653c 100644 --- a/app/src/terminal/available_shells.rs +++ b/app/src/terminal/available_shells.rs @@ -120,6 +120,7 @@ impl AvailableShell { "bash" => Cow::from("Bash"), "zsh" => Cow::from("Zsh"), "fish" => Cow::from("Fish"), + "nu" | "nu.exe" => Cow::from("Nushell"), "pwsh" | "pwsh.exe" => Cow::from("PowerShell"), "powershell" | "powershell.exe" => Cow::from("Windows PowerShell"), _ => Cow::from(command), @@ -631,6 +632,7 @@ impl AvailableShells { StartupShell::Zsh, StartupShell::Bash, StartupShell::Fish, + StartupShell::Nu, StartupShell::PowerShell, ] .into_iter() @@ -699,12 +701,14 @@ impl AvailableShells { (ShellType::Zsh, "zsh.exe"), (ShellType::Bash, "bash.exe"), (ShellType::Fish, "fish.exe"), + (ShellType::Nu, "nu.exe"), ] } else { vec![ (ShellType::Zsh, "zsh"), (ShellType::Bash, "bash"), (ShellType::Fish, "fish"), + (ShellType::Nu, "nu"), (ShellType::PowerShell, "pwsh"), ] } diff --git a/app/src/terminal/available_shells_test.rs b/app/src/terminal/available_shells_test.rs index 8abe79cbd..2832b1fe7 100644 --- a/app/src/terminal/available_shells_test.rs +++ b/app/src/terminal/available_shells_test.rs @@ -129,8 +129,10 @@ fn test_dedupe_symlinks_when_discovering_paths() { fn test_find_by_command_name_matches_known_shell() { let zsh_path = PathBuf::from("/bin/zsh"); let pwsh_path = PathBuf::from("/opt/homebrew/bin/pwsh"); + let nu_path = PathBuf::from("/opt/homebrew/bin/nu"); let shells = make_available_shells(vec![ AvailableShell::new_local_executable("zsh".to_string(), zsh_path.clone(), ShellType::Zsh), + AvailableShell::new_local_executable("nu".to_string(), nu_path.clone(), ShellType::Nu), AvailableShell::new_local_executable( "pwsh".to_string(), pwsh_path.clone(), @@ -153,6 +155,14 @@ fn test_find_by_command_name_matches_known_shell() { matched.id(), Some(format!("local:{}", zsh_path.display()).as_str()), ); + + let matched = shells + .find_by_command_name("nu") + .expect("should find nu by command name"); + assert_eq!( + matched.id(), + Some(format!("local:{}", nu_path.display()).as_str()), + ); } #[test] @@ -235,6 +245,7 @@ fn test_command_name_matches_unix() { // folding, no `.exe` suffix handling. assert!(command_name_matches("pwsh", "pwsh", false)); assert!(command_name_matches("zsh", "zsh", false)); + assert!(command_name_matches("nu", "nu", false)); assert!(!command_name_matches("pwsh", "PWSH", false)); assert!(!command_name_matches("pwsh", "pwsh.exe", false)); @@ -257,6 +268,7 @@ fn test_command_name_matches_windows() { assert!(command_name_matches("pwsh", "pwsh.exe", true)); assert!(command_name_matches("pwsh.exe", "PWSH.EXE", true)); assert!(command_name_matches("powershell.exe", "PowerShell", true)); + assert!(command_name_matches("nu.exe", "nu", true)); // Distinct shells should not collide. assert!(!command_name_matches("pwsh", "powershell", true)); diff --git a/app/src/terminal/bootstrap.rs b/app/src/terminal/bootstrap.rs index 0c89a6942..55fa75b21 100644 --- a/app/src/terminal/bootstrap.rs +++ b/app/src/terminal/bootstrap.rs @@ -74,6 +74,7 @@ pub fn should_use_rc_file_bootstrap_method( .as_ref() .is_some_and(|data| matches!(data, ShellLaunchData::MSYS2 { .. })); shell_type == ShellType::Fish + || shell_type == ShellType::Nu || shell_type == ShellType::PowerShell || is_poetry_subshell || ((is_pipenv_subshell @@ -105,6 +106,7 @@ pub fn script_for_shell(shell_type: ShellType, assets: &dyn AssetProvider) -> Co ShellType::Bash => "bash.sh", ShellType::Zsh => "zsh.sh", ShellType::Fish => "fish.sh", + ShellType::Nu => "nu.nu", ShellType::PowerShell => "pwsh.ps1", }; @@ -197,6 +199,7 @@ pub fn init_shell_script_for_shell(shell_type: ShellType, assets: &dyn AssetProv ShellType::Zsh => load_and_escape_script("bundled/bootstrap/zsh_init_shell.sh", assets), ShellType::Bash => load_and_escape_script("bundled/bootstrap/bash_init_shell.sh", assets), ShellType::Fish => load_and_escape_script("bundled/bootstrap/fish_init_shell.sh", assets), + ShellType::Nu => load_script("bundled/bootstrap/nu_init_shell.nu", assets), ShellType::PowerShell => load_script("bundled/bootstrap/pwsh_init_shell.ps1", assets), } } @@ -256,7 +259,7 @@ fn init_subshell_script_for_shell( load_and_escape_script("bundled/bootstrap/fish_init_subshell.sh", assets) } // TODO(PLAT-750) - ShellType::PowerShell => todo!(), + ShellType::PowerShell | ShellType::Nu => todo!(), }; // Combine the environment setup script with the shell-specific init script @@ -289,6 +292,7 @@ pub fn raw_init_shell_script_for_shell( ShellType::Bash => "bundled/bootstrap/bash_init_shell.sh", ShellType::Zsh => "bundled/bootstrap/zsh_init_shell.sh", ShellType::Fish => "bundled/bootstrap/fish_init_shell.sh", + ShellType::Nu => "bundled/bootstrap/nu_init_shell.nu", ShellType::PowerShell => "bundled/bootstrap/pwsh_init_shell.ps1", }; load_script(file, assets).replace("@@USING_CON_PTY_BOOLEAN@@", &(cfg!(windows).to_string())) diff --git a/app/src/terminal/bootstrap_test.rs b/app/src/terminal/bootstrap_test.rs index 18f13a838..6274ee371 100644 --- a/app/src/terminal/bootstrap_test.rs +++ b/app/src/terminal/bootstrap_test.rs @@ -7,6 +7,7 @@ impl AssetProvider for TestAssetProvider { let content = match path { "bundled/bootstrap/bash.sh" => "#include hello_world", "bundled/bootstrap/fish.sh" => "# this is a comment\nthis_is_a_command", + "bundled/bootstrap/nu.nu" => "# this is a comment\nnu_command", "bundled/bootstrap/zsh.sh" => { "asdf\n#include whitespace\n prepended whitespace\n\n\n" } @@ -41,6 +42,10 @@ fn test_trims_comments() { decode_script(&script_for_shell(ShellType::Fish, &TestAssetProvider)), "this_is_a_command\n" ); + assert_eq!( + decode_script(&script_for_shell(ShellType::Nu, &TestAssetProvider)), + "nu_command\n" + ); } #[test] diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 5e5d1af8f..1f88b039a 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -6714,7 +6714,7 @@ impl Input { // Add newlines at the end to separate the vars from the comment/command Some(format!( "# Environment variables\n{}\n\n", - env_vars.export_variables(" ", shell_type.into()) + env_vars.export_variables(" ", shell_type) )) } } diff --git a/app/src/terminal/local_shell/mod.rs b/app/src/terminal/local_shell/mod.rs index cc561319c..d229f5dcc 100644 --- a/app/src/terminal/local_shell/mod.rs +++ b/app/src/terminal/local_shell/mod.rs @@ -106,6 +106,7 @@ impl LocalShellState { let command = match shell_type { ShellType::Bash | ShellType::Zsh => "echo $PATH", ShellType::Fish => "env | grep PATH", + ShellType::Nu => "$env.PATH | str join (char esep)", ShellType::PowerShell => "echo $Env:PATH", }; @@ -248,6 +249,7 @@ async fn capture_interactive_shell_env( let command_str = match shell_type { ShellType::Bash | ShellType::Zsh => "echo $PATH", ShellType::Fish => "string join : $PATH", + ShellType::Nu => "$env.PATH | str join (char esep)", ShellType::PowerShell => "echo $Env:PATH", }; @@ -276,6 +278,9 @@ async fn capture_interactive_shell_env( ShellType::Fish => { command.args(["-i", "-l", "-c", command_str]); } + ShellType::Nu => { + command.args(["-i", "-l", "-c", command_str]); + } ShellType::PowerShell => { // Note: we intentionally omit `-Login` here. PowerShell 5.1 // (`powershell.exe`) does not support it, and on Windows the diff --git a/app/src/terminal/local_tty/shell.rs b/app/src/terminal/local_tty/shell.rs index f94e09e89..36f06af67 100644 --- a/app/src/terminal/local_tty/shell.rs +++ b/app/src/terminal/local_tty/shell.rs @@ -27,6 +27,7 @@ use crate::util::windows::{powershell_5_path, powershell_7_path, wsl_path}; pub const ZSH_SHELL_PATH: &str = "/bin/zsh"; pub const BASH_SHELL_PATH: &str = "/bin/bash"; pub const FISH_SHELL_PATH: &str = "/bin/fish"; +pub const NU_SHELL_PATH: &str = "/bin/nu"; /// Returns an iterator of additional PATH entries to append to the shell's PATH. /// * On macOS, this includes `$APP_PATH/Contents/Resources/bin`, in which we put a wrapper around the Warp CLI. @@ -49,7 +50,7 @@ pub fn extra_path_entries() -> impl Iterator { } /// Returns `true` if the given `path_or_command` is a valid, executable command or path to a -/// executable binary for one of Warp's supported shell types (bash, fish, zsh). +/// executable binary for one of Warp's supported shell types (bash, fish, nu, zsh). pub fn is_valid_path_or_command_for_supported_shell(path_or_command: &str) -> bool { supported_shell_path_and_type(path_or_command).is_some() } @@ -208,8 +209,10 @@ impl ShellStarter { shell_path_and_type } else if let Some(shell_path_and_type) = supported_shell_path_and_type(FISH_SHELL_PATH) { shell_path_and_type + } else if let Some(shell_path_and_type) = supported_shell_path_and_type(NU_SHELL_PATH) { + shell_path_and_type } else { - log::warn!("Did not find valid binaries when attempting to load fallback shell (not bash, fish, or zsh)."); + log::warn!("Did not find valid binaries when attempting to load fallback shell (not bash, fish, nu, or zsh)."); return None; }; @@ -471,13 +474,7 @@ impl WslShellStarter { // We don't need to check the validity of the path or the existence of the binary since // we get this information directly from a spun-up shell in WSL. - let shell_type = if shell_path.contains("bash") { - ShellType::Bash - } else if shell_path.contains("zsh") { - ShellType::Zsh - } else if shell_path.contains("fish") { - ShellType::Fish - } else { + let Some(shell_type) = wsl_shell_type_from_path(&shell_path) else { log::warn!("The shell {shell_path:#} is not yet supported in WSL"); return None; }; @@ -535,6 +532,11 @@ impl WslShellStarter { } } +fn wsl_shell_type_from_path(shell_path: &str) -> Option { + let shell_name = shell_path.rsplit('/').next().unwrap_or(shell_path); + ShellType::from_name(shell_name) +} + /// If the given `path_or_command` resolves to a supported shell binary, returns a tuple /// containing the resolved path to the binary and the corresponding `ShellType`. Else, returns /// None. @@ -636,6 +638,11 @@ fn arguments_for_session_spawning_command( .into(), ] } + ShellType::Nu => vec![ + "--login".to_owned().into(), + "--execute".to_owned().into(), + init_shell_script_for_shell(ShellType::Nu, &crate::ASSETS).into(), + ], ShellType::PowerShell => vec![ // When PowerShell starts a session, it writes "PowerShell " to the PTY. This // option suppresses that message. @@ -670,7 +677,7 @@ fn wsl_arguments_for_session_spawning_command( // Note we typically go through bash so that we can launch the user's shell // with a leading '-', making it a login shell. match shell_type { - ShellType::Bash | ShellType::Zsh | ShellType::Fish => { + ShellType::Bash | ShellType::Zsh | ShellType::Fish | ShellType::Nu => { args.extend(arguments_for_session_spawning_command( shell_path, shell_type, )); @@ -697,6 +704,7 @@ fn msys2_arguments_for_session_spawning_command(shell_type: ShellType) -> Vec vec!["--login".to_string().into()], ShellType::PowerShell => panic!("MSYS2 not supported for PowerShell"), } } diff --git a/app/src/terminal/local_tty/shell_tests.rs b/app/src/terminal/local_tty/shell_tests.rs index e2d8c4b74..851f272c3 100644 --- a/app/src/terminal/local_tty/shell_tests.rs +++ b/app/src/terminal/local_tty/shell_tests.rs @@ -20,6 +20,33 @@ fn test_program_unknown_shell() { assert!(supported_shell_path_and_type(&shell_path).is_none()); } +#[test] +fn test_program_nu_shell() { + assert!(matches!( + parse_shell_type_from_path(Path::new("/usr/bin/nu")), + Some((_, ShellType::Nu)) + )); +} + +#[test] +fn test_program_nu_exe_shell() { + assert!(matches!( + parse_shell_type_from_path(Path::new( + "C:\\Users\\user\\scoop\\apps\\nu\\current\\nu.exe" + )), + Some((_, ShellType::Nu)) + )); + assert!( + parse_shell_type_from_path(Path::new("C:\\Users\\user\\scoop\\apps\\menu.exe")).is_none() + ); +} + +#[test] +fn test_wsl_shell_type_from_path_matches_basename() { + assert_eq!(wsl_shell_type_from_path("/usr/bin/nu"), Some(ShellType::Nu)); + assert_eq!(wsl_shell_type_from_path("/usr/bin/menu.exe"), None); +} + #[test] fn test_trim_wsl_err_from_output() { assert_eq!( diff --git a/app/src/terminal/model/session.rs b/app/src/terminal/model/session.rs index 723779c93..438c37721 100644 --- a/app/src/terminal/model/session.rs +++ b/app/src/terminal/model/session.rs @@ -726,7 +726,7 @@ impl SessionInfo { // a separate line. let split = match &self.shell.shell_type() { ShellType::Zsh | ShellType::PowerShell => names.split(' '), - ShellType::Bash | ShellType::Fish => names.split('\n'), + ShellType::Bash | ShellType::Fish | ShellType::Nu => names.split('\n'), }; split.map(Into::into).collect::>() }); @@ -936,7 +936,9 @@ impl Session { pub fn path_separators(&self) -> PathSeparators { match self.shell().shell_type() { - ShellType::Zsh | ShellType::Bash | ShellType::Fish => PathSeparators::for_unix(), + ShellType::Zsh | ShellType::Bash | ShellType::Fish | ShellType::Nu => { + PathSeparators::for_unix() + } ShellType::PowerShell => PathSeparators::for_os(), } } diff --git a/app/src/terminal/model/session/command_executor/in_band_command_executor.rs b/app/src/terminal/model/session/command_executor/in_band_command_executor.rs index 3f27b0124..7cbff5e68 100644 --- a/app/src/terminal/model/session/command_executor/in_band_command_executor.rs +++ b/app/src/terminal/model/session/command_executor/in_band_command_executor.rs @@ -331,6 +331,14 @@ impl InBandCommandExecutor { ShellType::PowerShell => { format!("Warp-Run-GeneratorCommand {id} '{escaped_command}' -ErrorAction Ignore") } + ShellType::Nu => { + let nu_escaped_command = command + .replace('\\', r"\\") + .replace('"', r#"\""#) + .replace('\n', r"\n") + .replace('\r', r"\r"); + format!("warp_run_generator_command {id} \"{nu_escaped_command}\"") + } ShellType::Fish => { // Add a leading space for in-band commands in fish, which omits them from // history. Unlike bash and zsh, fish does not have a mechanism for diff --git a/app/src/terminal/model/session/command_executor/local_command_executor.rs b/app/src/terminal/model/session/command_executor/local_command_executor.rs index eafe5e3aa..e0affcdc8 100644 --- a/app/src/terminal/model/session/command_executor/local_command_executor.rs +++ b/app/src/terminal/model/session/command_executor/local_command_executor.rs @@ -92,6 +92,7 @@ impl LocalCommandExecutor { ShellType::Zsh => "-f", ShellType::Bash => "--norc", ShellType::Fish => "--no-config", + ShellType::Nu => "--no-config-file", ShellType::PowerShell => "-NoProfile", }; @@ -112,7 +113,7 @@ impl LocalCommandExecutor { environment_variables: Option>, ) -> Result { let shell_config_flag = match self.shell_type { - ShellType::Bash | ShellType::Zsh | ShellType::Fish => "-l", + ShellType::Bash | ShellType::Zsh | ShellType::Fish | ShellType::Nu => "-l", ShellType::PowerShell => "-Login", }; diff --git a/app/src/terminal/model/session/command_executor/shared.rs b/app/src/terminal/model/session/command_executor/shared.rs index 1887a801d..28197dbac 100644 --- a/app/src/terminal/model/session/command_executor/shared.rs +++ b/app/src/terminal/model/session/command_executor/shared.rs @@ -34,6 +34,7 @@ pub fn shell_escape_single_quotes(command: &str, shell_type: ShellType) -> Strin // In powershell we escape single quotes using two single quotes '' command.replace('\'', "''") } + ShellType::Nu => command.replace('\'', "''"), _ => { // For Bash and Zsh, replace each single quote with a '"'"' sequence. // The first single quote completes the single quoted string to the left, diff --git a/app/src/terminal/model/session/command_executor/wsl_command_executor.rs b/app/src/terminal/model/session/command_executor/wsl_command_executor.rs index 0563b09ee..42614857e 100644 --- a/app/src/terminal/model/session/command_executor/wsl_command_executor.rs +++ b/app/src/terminal/model/session/command_executor/wsl_command_executor.rs @@ -37,6 +37,7 @@ impl WslCommandExecutor { ShellType::Zsh => "-f", ShellType::Bash => "--norc", ShellType::Fish => "--no-config", + ShellType::Nu => "--no-config-file", ShellType::PowerShell => "-NoProfile", }; diff --git a/app/src/terminal/model/terminal_model.rs b/app/src/terminal/model/terminal_model.rs index 875663393..ab661e363 100644 --- a/app/src/terminal/model/terminal_model.rs +++ b/app/src/terminal/model/terminal_model.rs @@ -3014,6 +3014,14 @@ impl ansi::Handler for TerminalModel { shell_type, uname: data.uname, })), + Some(ShellType::Nu) => { + self.event_proxy + .send_terminal_event(Event::RemoteWarpificationIsUnavailable( + WarpificationUnavailableReason::UnsupportedShell { + shell_name: data.shell, + }, + )) + } _ => self .event_proxy .send_terminal_event(Event::RemoteWarpificationIsUnavailable( diff --git a/app/src/terminal/session_settings/startup_shell.rs b/app/src/terminal/session_settings/startup_shell.rs index 251a888d7..dfe2b3943 100644 --- a/app/src/terminal/session_settings/startup_shell.rs +++ b/app/src/terminal/session_settings/startup_shell.rs @@ -2,19 +2,20 @@ use serde::{Deserialize, Deserializer, Serialize}; /// A user setting for the shell to start new terminal sessions with. /// -/// Users choose between their login shell, the default versions of zsh/bash/fish +/// Users choose between their login shell, the default versions of zsh/bash/fish/nushell /// (if installed, the first matching executable on their `$PATH`), and a /// custom path or command. #[derive(Debug, Clone, Default, PartialEq, Eq, schemars::JsonSchema)] #[schemars( with = "Option", - description = "Shell to start terminal sessions with. Use null for the system default, or one of \"bash\", \"zsh\", \"fish\", \"pwsh\", or a custom shell command/path." + description = "Shell to start terminal sessions with. Use null for the system default, or one of \"bash\", \"zsh\", \"fish\", \"nu\", \"pwsh\", or a custom shell command/path." )] pub enum StartupShell { #[default] Default, Bash, Fish, + Nu, Zsh, PowerShell, Custom(String), @@ -27,6 +28,7 @@ impl StartupShell { StartupShell::Default => None, StartupShell::Bash => Some("bash"), StartupShell::Fish => Some("fish"), + StartupShell::Nu => Some("nu"), StartupShell::Zsh => Some("zsh"), StartupShell::PowerShell => Some("pwsh"), StartupShell::Custom(shell) => Some(shell), @@ -62,6 +64,7 @@ impl From> for StartupShell { Some(shell) if shell == "bash" => StartupShell::Bash, Some(shell) if shell == "zsh" => StartupShell::Zsh, Some(shell) if shell == "fish" => StartupShell::Fish, + Some(shell) if shell == "nu" => StartupShell::Nu, Some(shell) if shell == "pwsh" => StartupShell::PowerShell, Some(shell) => StartupShell::Custom(shell), } diff --git a/app/src/terminal/ssh/warpify.rs b/app/src/terminal/ssh/warpify.rs index e520aa6e9..147d3df3d 100644 --- a/app/src/terminal/ssh/warpify.rs +++ b/app/src/terminal/ssh/warpify.rs @@ -172,8 +172,8 @@ pub fn warpify_ssh_session_command( bundled_asset!("ssh/bash_zsh/warpify_ssh_session.sh") } (_, ShellType::Fish) => bundled_asset!("ssh/fish/warpify_ssh_session.sh"), - // PowerShell is not supported yet. - (_, ShellType::PowerShell) => return None, + // PowerShell and Nushell are not supported yet for SSH warpify. + (_, ShellType::PowerShell | ShellType::Nu) => return None, }; // Todo(Jack): look into avoiding an allocation here. diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 559c407cf..3bc465ec2 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -23590,10 +23590,16 @@ impl TerminalView { let env_var_collection = cloud_env_var_collection.model().string_model.clone(); let (shell_path_string, shell_type) = shell_session_info; - if shell_type == ShellType::PowerShell { + if matches!(shell_type, ShellType::PowerShell | ShellType::Nu) { ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { - let toast = - DismissibleToast::error("PowerShell subshells not supported".to_owned()); + let toast = DismissibleToast::error(format!( + "{} subshells not supported", + if shell_type == ShellType::PowerShell { + "PowerShell" + } else { + "Nushell" + } + )); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); return; diff --git a/app/src/terminal/warpify/mod.rs b/app/src/terminal/warpify/mod.rs index 237396d3e..7f4d8f3e4 100644 --- a/app/src/terminal/warpify/mod.rs +++ b/app/src/terminal/warpify/mod.rs @@ -29,7 +29,7 @@ fn get_subshell_bootstrap_success_block_path(shell_type: ShellType) -> Option<&' Some("bundled/bootstrap/bash_zsh_subshell_bootstrap_block_output.txt") } ShellType::Fish => Some("bundled/bootstrap/fish_subshell_bootstrap_block_output.txt"), - ShellType::PowerShell => None, + ShellType::PowerShell | ShellType::Nu => None, } } diff --git a/app/src/terminal/writeable_pty/bootstrap_file/mod.rs b/app/src/terminal/writeable_pty/bootstrap_file/mod.rs index fff0c07e3..ccc513bad 100644 --- a/app/src/terminal/writeable_pty/bootstrap_file/mod.rs +++ b/app/src/terminal/writeable_pty/bootstrap_file/mod.rs @@ -20,9 +20,16 @@ where S: AsRef, { let mut builder = tempfile::Builder::new(); - // PowerShell will only source a file with the "ps1" extension. - if shell_type == ShellType::PowerShell { - builder.suffix(".ps1"); + // PowerShell will only source a file with the "ps1" extension. Nushell can source extensionless + // files, but a ".nu" suffix keeps temporary bootstrap files recognizable while debugging. + match shell_type { + ShellType::PowerShell => { + builder.suffix(".ps1"); + } + ShellType::Nu => { + builder.suffix(".nu"); + } + _ => {} } match TempBootstrapFile::new( diff --git a/crates/warp_terminal/src/shell/mod.rs b/crates/warp_terminal/src/shell/mod.rs index 471995ee6..a4f2ee521 100644 --- a/crates/warp_terminal/src/shell/mod.rs +++ b/crates/warp_terminal/src/shell/mod.rs @@ -153,6 +153,7 @@ impl Shell { .is_some_and(|map| map.contains("autocd")), // autocd is always enabled in Fish, see https://fishshell.com/docs/current/cmds/cd.html. ShellType::Fish => true, + ShellType::Nu => false, ShellType::PowerShell => false, } } @@ -167,7 +168,9 @@ impl Shell { pub fn input_reporting_sequence(&self) -> Option<[u8; 2]> { match self.shell_type { ShellType::PowerShell => Some([escape_sequences::C0::ESC, b'1']), - ShellType::Fish | ShellType::Zsh => Some([escape_sequences::C0::ESC, b'i']), + ShellType::Fish | ShellType::Nu | ShellType::Zsh => { + Some([escape_sequences::C0::ESC, b'i']) + } ShellType::Bash => self .version .as_ref() @@ -251,6 +254,7 @@ pub enum ShellType { Zsh, Bash, Fish, + Nu, PowerShell, } @@ -260,6 +264,10 @@ impl From for command_corrections::Shell { ShellType::Bash => command_corrections::Shell::Bash, ShellType::Zsh => command_corrections::Shell::Zsh, ShellType::Fish => command_corrections::Shell::Fish, + // The command-corrections crate does not have a Nushell dialect yet. Use Bash as a + // conservative fallback so correction requests can still be made instead of failing at + // the type-conversion boundary. + ShellType::Nu => command_corrections::Shell::Bash, ShellType::PowerShell => command_corrections::Shell::PowerShell, } } @@ -268,15 +276,25 @@ impl From for command_corrections::Shell { impl From for warp_util::path::ShellFamily { fn from(value: ShellType) -> Self { match value { - ShellType::Zsh | ShellType::Bash | ShellType::Fish => Self::Posix, + ShellType::Zsh | ShellType::Bash | ShellType::Fish | ShellType::Nu => Self::Posix, ShellType::PowerShell => Self::PowerShell, } } } +impl From for ShellType { + fn from(value: warp_util::path::ShellFamily) -> Self { + match value { + warp_util::path::ShellFamily::Posix => ShellType::Bash, + warp_util::path::ShellFamily::PowerShell => ShellType::PowerShell, + } + } +} + impl ShellType { // Returns a shell type from a shell executable name pub fn from_name(name: &str) -> Option { + let executable_name = name.rsplit(['/', '\\']).next().unwrap_or(name); // Support (/usr/bin/zsh /bin/zsh -zsh or zsh) if name == "bash" || name == "-bash" @@ -288,6 +306,12 @@ impl ShellType { Some(ShellType::Zsh) } else if name == "fish" || name == "-fish" || name.ends_with("/fish") { Some(ShellType::Fish) + } else if name == "nu" + || name == "-nu" + || name.ends_with("/nu") + || executable_name == "nu.exe" + { + Some(ShellType::Nu) } else if name == "pwsh" || name.ends_with("/pwsh") || name.ends_with("pwsh.exe") @@ -307,6 +331,7 @@ impl ShellType { "bash" | "shell" | "sh" => Some(ShellType::Bash), "zsh" => Some(ShellType::Zsh), "fish" => Some(ShellType::Fish), + "nu" | "nushell" => Some(ShellType::Nu), "powershell" | "pwsh" => Some(ShellType::PowerShell), _ => None, } @@ -318,6 +343,10 @@ impl ShellType { ShellType::Zsh => vec!["~/.zsh_history".to_string(), "~/.zhistory".to_string()], ShellType::Bash => vec!["~/.bash_history".to_string()], ShellType::Fish => vec!["~/.local/share/fish/fish_history".to_string()], + ShellType::Nu => vec![ + "~/.config/nushell/history.txt".to_string(), + "~/.local/share/nushell/history.txt".to_string(), + ], #[cfg(not(windows))] ShellType::PowerShell => { vec!["~/.local/share/powershell/PSReadLine/ConsoleHost_history.txt".to_string()] @@ -354,6 +383,11 @@ impl ShellType { (ShellType::Bash, _) => vec![Path::new(".bashrc")], (ShellType::Zsh, _) => vec![Path::new(".zshrc")], (ShellType::Fish, _) => vec![Path::new(".config/fish/config.fish")], + (ShellType::Nu, _) => vec![ + Path::new(".config/nushell/env.nu"), + Path::new(".config/nushell/config.nu"), + Path::new(".config/nushell/login.nu"), + ], }; relative_paths .iter() @@ -369,6 +403,10 @@ impl ShellType { match self { ShellType::Bash | ShellType::Zsh | ShellType::PowerShell => " && ", ShellType::Fish => "; and ", + // Nushell does not support POSIX `&&`. There is no direct command separator with the + // same status-gated semantics, so use a plain separator as the least surprising + // fallback for command construction sites that are not Nushell-specific. + ShellType::Nu => "; ", } } @@ -382,6 +420,7 @@ impl ShellType { match self { ShellType::Bash | ShellType::Zsh | ShellType::PowerShell => " ; ", ShellType::Fish => "; or ", + ShellType::Nu => "; ", } } @@ -428,6 +467,13 @@ impl ShellType { }) .collect() } + ShellType::Nu => alias_output + .lines() + .filter_map(|line| { + let (key, value) = line.split_once('\t')?; + Some((key.into(), value.to_string())) + }) + .collect(), ShellType::PowerShell => alias_output .lines() .filter_map(|line| { @@ -536,6 +582,15 @@ impl ShellType { .map(|s| s.to_owned()) .collect() } + + ShellType::Nu => { + let history_file_contents = String::from_utf8_lossy(history_file_bytes); + history_lines = history_file_contents + .lines() + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()) + .collect() + } } history_lines @@ -554,14 +609,16 @@ impl ShellType { const OTHER_BINDING: [u8; 1] = [escape_sequences::C0::DLE]; match self { ShellType::PowerShell => POWERSHELL_BINDING.as_slice(), - ShellType::Zsh | ShellType::Bash | ShellType::Fish => OTHER_BINDING.as_slice(), + ShellType::Zsh | ShellType::Bash | ShellType::Fish | ShellType::Nu => { + OTHER_BINDING.as_slice() + } } } /// Bytes used to execute a command, once the command text is sent pub fn execute_command_bytes(self) -> &'static [u8] { match self { - ShellType::Bash | ShellType::Zsh => &b"\n"[..], + ShellType::Bash | ShellType::Zsh | ShellType::Nu => &b"\n"[..], ShellType::PowerShell => &b"\r"[..], // For Fish, we send an extra space, immediately followed by backspace, and then // the newline character. The backspace ensures that any autosuggestions are @@ -577,6 +634,7 @@ impl ShellType { ShellType::Zsh => "zsh", ShellType::Bash => "bash", ShellType::Fish => "fish", + ShellType::Nu => "nu", ShellType::PowerShell => "pwsh", } } @@ -585,7 +643,7 @@ impl ShellType { pub fn is_fully_supported_remotely(&self) -> bool { match self { ShellType::Zsh | ShellType::Bash => true, - ShellType::Fish | ShellType::PowerShell => false, + ShellType::Fish | ShellType::Nu | ShellType::PowerShell => false, } } @@ -621,6 +679,9 @@ impl ShellType { // per line, we explicitly join the results with a newline. "Get-Command -CommandType Application | Select-Object -ExpandProperty Name" } + ShellType::Nu => { + "$env.PATH | each { |dir| try { ls $dir | where type in [file symlink] | get name | path basename } catch { [] } } | flatten | uniq | str join (char nl)" + } } } @@ -638,7 +699,7 @@ impl ShellType { return Vec::new(); }; match self { - ShellType::Bash | ShellType::Zsh => { + ShellType::Bash | ShellType::Zsh | ShellType::Nu => { // For bash and zsh, we wrote the command such that the output is just // a list of executable files. if !is_msys2 { diff --git a/crates/warp_terminal/src/shell/mod_tests.rs b/crates/warp_terminal/src/shell/mod_tests.rs index c56fc9660..c9a6fc900 100644 --- a/crates/warp_terminal/src/shell/mod_tests.rs +++ b/crates/warp_terminal/src/shell/mod_tests.rs @@ -107,6 +107,16 @@ fn test_from_name() { Some(ShellType::Fish), ShellType::from_name("/usr/local/bin/fish") ); + assert_eq!(Some(ShellType::Nu), ShellType::from_name("nu")); + assert_eq!(Some(ShellType::Nu), ShellType::from_name("-nu")); + assert_eq!(Some(ShellType::Nu), ShellType::from_name("/usr/bin/nu")); + assert_eq!(Some(ShellType::Nu), ShellType::from_name("nu.exe")); + assert_eq!( + Some(ShellType::Nu), + ShellType::from_name("C:\\Users\\user\\scoop\\apps\\nu\\current\\nu.exe") + ); + assert_eq!(None, ShellType::from_name("menu.exe")); + assert_eq!(None, ShellType::from_name("nush")); assert_eq!( Some(ShellType::PowerShell), ShellType::from_name("pwsh.exe") @@ -154,6 +164,14 @@ fn test_from_markdown_language_spec() { Some(ShellType::PowerShell), ShellType::from_markdown_language_spec("pwsh") ); + assert_eq!( + Some(ShellType::Nu), + ShellType::from_markdown_language_spec("nu") + ); + assert_eq!( + Some(ShellType::Nu), + ShellType::from_markdown_language_spec("nushell") + ); // Non-shell languages and invalid inputs assert_eq!(None, ShellType::from_markdown_language_spec("python")); @@ -192,6 +210,16 @@ alias ehw 'echo \"Hello, world\"'"; assert_eq!(aliases.get("ehw").unwrap(), r#"echo "Hello, world""#); } +#[test] +fn test_nu_parse_aliases() { + let raw_aliases = "g\tgit status\nll\tls -la"; + let aliases = ShellType::Nu.aliases(raw_aliases); + + assert_eq!(aliases.len(), 2); + assert_eq!(aliases.get("g").unwrap(), "git status"); + assert_eq!(aliases.get("ll").unwrap(), "ls -la"); +} + #[test] fn test_should_add_command_to_history() { {