From 59c6f95d87b62f10c33fd758bb53970f47451b62 Mon Sep 17 00:00:00 2001 From: Lucas Trzesniewski Date: Sun, 12 Jan 2025 20:44:47 +0100 Subject: [PATCH 1/2] feat: add PowerShell module This adds PowerShell support by invoking the following expression: atuin init powershell | Out-String | Invoke-Expression Co-authored-by: Jason Shirk --- crates/atuin-common/src/utils.rs | 5 + crates/atuin-daemon/src/server.rs | 1 + crates/atuin-server/src/handlers/mod.rs | 2 +- crates/atuin/src/command/client/init.rs | 11 ++ .../src/command/client/init/powershell.rs | 24 +++ .../src/command/client/search/interactive.rs | 18 ++- crates/atuin/src/shell/atuin.ps1 | 143 ++++++++++++++++++ 7 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 crates/atuin/src/command/client/init/powershell.rs create mode 100644 crates/atuin/src/shell/atuin.ps1 diff --git a/crates/atuin-common/src/utils.rs b/crates/atuin-common/src/utils.rs index 7f156d77ef1..73296fb0822 100644 --- a/crates/atuin-common/src/utils.rs +++ b/crates/atuin-common/src/utils.rs @@ -133,6 +133,11 @@ pub fn is_xonsh() -> bool { env::var("ATUIN_SHELL_XONSH").is_ok() } +pub fn is_powershell() -> bool { + // only set on powershell + env::var("ATUIN_SHELL_POWERSHELL").is_ok() +} + /// Extension trait for anything that can behave like a string to make it easy to escape control /// characters. /// diff --git a/crates/atuin-daemon/src/server.rs b/crates/atuin-daemon/src/server.rs index c9f97a65c55..eaf9938605a 100644 --- a/crates/atuin-daemon/src/server.rs +++ b/crates/atuin-daemon/src/server.rs @@ -4,6 +4,7 @@ use atuin_client::encryption; use atuin_client::history::store::HistoryStore; use atuin_client::record::sqlite_store::SqliteStore; use atuin_client::settings::Settings; +#[cfg(unix)] use std::path::PathBuf; use std::sync::Arc; use time::OffsetDateTime; diff --git a/crates/atuin-server/src/handlers/mod.rs b/crates/atuin-server/src/handlers/mod.rs index ce10f4b70d8..55d76f39d37 100644 --- a/crates/atuin-server/src/handlers/mod.rs +++ b/crates/atuin-server/src/handlers/mod.rs @@ -56,7 +56,7 @@ impl<'a> RespExt<'a> for ErrorResponse<'a> { } } - fn reply(reason: &'a str) -> ErrorResponse { + fn reply(reason: &'a str) -> ErrorResponse<'a> { Self { reason: reason.into(), } diff --git a/crates/atuin/src/command/client/init.rs b/crates/atuin/src/command/client/init.rs index 8238a69bcf7..926474aec74 100644 --- a/crates/atuin/src/command/client/init.rs +++ b/crates/atuin/src/command/client/init.rs @@ -7,6 +7,7 @@ use eyre::{Result, WrapErr}; mod bash; mod fish; +mod powershell; mod xonsh; mod zsh; @@ -24,6 +25,8 @@ pub struct Cmd { } #[derive(Clone, Copy, ValueEnum, Debug)] +#[value(rename_all = "lower")] +#[allow(clippy::enum_variant_names, clippy::doc_markdown)] pub enum Shell { /// Zsh setup Zsh, @@ -35,6 +38,8 @@ pub enum Shell { Nu, /// Xonsh setup Xonsh, + /// PowerShell setup + PowerShell, } impl Cmd { @@ -100,6 +105,9 @@ $env.config = ( Shell::Xonsh => { xonsh::init_static(self.disable_up_arrow, self.disable_ctrl_r); } + Shell::PowerShell => { + powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r); + } }; } @@ -153,6 +161,9 @@ $env.config = ( ) .await?; } + Shell::PowerShell => { + powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r); + } } Ok(()) diff --git a/crates/atuin/src/command/client/init/powershell.rs b/crates/atuin/src/command/client/init/powershell.rs new file mode 100644 index 00000000000..1185ec6ff27 --- /dev/null +++ b/crates/atuin/src/command/client/init/powershell.rs @@ -0,0 +1,24 @@ +pub fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) { + let base = include_str!("../../../shell/atuin.ps1"); + + let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { + (false, false) + } else { + (!disable_ctrl_r, !disable_up_arrow) + }; + + println!("{base}"); + println!( + "Enable-AtuinSearchKeys -CtrlR {} -UpArrow {}", + ps_bool(bind_ctrl_r), + ps_bool(bind_up_arrow) + ); +} + +fn ps_bool(value: bool) -> &'static str { + if value { + "$true" + } else { + "$false" + } +} diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index c1a678f0872..d6ee793cfa0 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -33,8 +33,7 @@ use ratatui::{ cursor::SetCursorStyle, event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers, - KeyboardEnhancementFlags, MouseEvent, PopKeyboardEnhancementFlags, - PushKeyboardEnhancementFlags, + MouseEvent, }, execute, terminal, }, @@ -46,6 +45,11 @@ use ratatui::{ Frame, Terminal, TerminalOptions, Viewport, }; +#[cfg(not(target_os = "windows"))] +use ratatui::crossterm::event::{ + KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, +}; + const TAB_TITLES: [&str; 2] = ["Search", "Inspect"]; pub enum InputAction { @@ -1098,6 +1102,10 @@ pub async fn history( let mut results = app.query_results(&mut db, settings.smart_sort).await?; + if settings.inline_height > 0 { + terminal.clear()?; + } + let mut stats: Option = None; let accept; let result = 'render: loop { @@ -1180,7 +1188,11 @@ pub async fn history( InputAction::Accept(index) if index < results.len() => { let mut command = results.swap_remove(index).command; if accept - && (utils::is_zsh() || utils::is_fish() || utils::is_bash() || utils::is_xonsh()) + && (utils::is_zsh() + || utils::is_fish() + || utils::is_bash() + || utils::is_xonsh() + || utils::is_powershell()) { command = String::from("__atuin_accept__:") + &command; } diff --git a/crates/atuin/src/shell/atuin.ps1 b/crates/atuin/src/shell/atuin.ps1 new file mode 100644 index 00000000000..4ae4eb0ad11 --- /dev/null +++ b/crates/atuin/src/shell/atuin.ps1 @@ -0,0 +1,143 @@ +# Atuin PowerShell module +# +# Usage: atuin init powershell | Out-String | Invoke-Expression + +if (Get-Module Atuin -ErrorAction Ignore) { + Write-Warning "The Atuin module is already loaded." + return +} + +if (!(Get-Command atuin -ErrorAction Ignore)) { + Write-Error "The 'atuin' executable needs to be available in the PATH." + return +} + +if (!(Get-Module PSReadLine -ErrorAction Ignore)) { + Write-Error "Atuin requires the PSReadLine module to be installed." + return +} + +New-Module -Name Atuin -ScriptBlock { + $env:ATUIN_SESSION = atuin uuid + + $script:atuinHistoryId = $null + $script:previousPSConsoleHostReadLine = $Function:PSConsoleHostReadLine + + # The ReadLine overloads changed with breaking changes over time, make sure the one we expect is available. + $script:hasExpectedReadLineOverload = ([Microsoft.PowerShell.PSConsoleReadLine]::ReadLine).OverloadDefinitions.Contains("static string ReadLine(runspace runspace, System.Management.Automation.EngineIntrinsics engineIntrinsics, System.Threading.CancellationToken cancellationToken, System.Nullable[bool] lastRunStatus)") + + function PSConsoleHostReadLine { + # This needs to be done as the first thing because any script run will flush $?. + $lastRunStatus = $? + + # Exit statuses are maintained separately for native and PowerShell commands, this needs to be taken into account. + $exitCode = if ($lastRunStatus) { 0 } elseif ($global:LASTEXITCODE) { $global:LASTEXITCODE } else { 1 } + + if ($script:atuinHistoryId) { + # The duration is not recorded in old PowerShell versions, let Atuin handle it. + $duration = (Get-History -Count 1).Duration.Ticks * 100 + $durationArg = if ($duration) { "--duration=$duration" } else { "" } + + atuin history end --exit=$exitCode $durationArg -- $script:atuinHistoryId | Out-Null + + $global:LASTEXITCODE = $exitCode + $script:atuinHistoryId = $null + } + + # PSConsoleHostReadLine implementation from PSReadLine, adjusted to support old versions. + Microsoft.PowerShell.Core\Set-StrictMode -Off + + $line = if ($script:hasExpectedReadLineOverload) { + # When the overload we expect is available, we can pass $lastRunStatus to it. + [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($Host.Runspace, $ExecutionContext, [System.Threading.CancellationToken]::None, $lastRunStatus) + } else { + # Either PSReadLine is older than v2.2.0-beta3, or maybe newer than we expect, so use the function from PSReadLine as-is. + & $script:previousPSConsoleHostReadLine + } + + $script:atuinHistoryId = atuin history start -- $line + + return $line + } + + function RunSearch { + param([string]$ExtraArgs = "") + + $line = $null + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) + + # Atuin is started through Start-Process to avoid interfering with the current shell, + # and to capture its output which is provided in stderr (redirected to a temporary file). + + $suggestion = "" + $resultFile = New-TemporaryFile + try { + $env:ATUIN_SHELL_POWERSHELL = "true" + $argString = "search -i $ExtraArgs -- $line" + Start-Process -Wait -NoNewWindow -RedirectStandardError $resultFile.FullName -FilePath atuin -ArgumentList $argString + $suggestion = (Get-Content -Raw $resultFile -Encoding UTF8 | Out-String).Trim() + } + finally { + $env:ATUIN_SHELL_POWERSHELL = $null + Remove-Item $resultFile + } + + $previousOutputEncoding = [System.Console]::OutputEncoding + try { + [System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + + # PSReadLine maintains its own cursor position, which will no longer be valid if Atuin scrolls the display in inline mode. + # Fortunately, InvokePrompt can receive a new Y position and reset the internal state. + [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt($null, $Host.UI.RawUI.CursorPosition.Y + [int]$env:ATUIN_POWERSHELL_PROMPT_OFFSET) + + if ($suggestion -eq "") { + # The previous input was already rendered by InvokePrompt + return + } + + $acceptPrefix = "__atuin_accept__:" + + if ( $suggestion.StartsWith($acceptPrefix)) { + [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() + [Microsoft.PowerShell.PSConsoleReadLine]::Insert($suggestion.Substring($acceptPrefix.Length)) + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() + } else { + [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() + [Microsoft.PowerShell.PSConsoleReadLine]::Insert($suggestion) + } + } + finally { + [System.Console]::OutputEncoding = $previousOutputEncoding + } + } + + function Enable-AtuinSearchKeys { + param([bool]$CtrlR = $true, [bool]$UpArrow = $true) + + if ($CtrlR) { + Set-PSReadLineKeyHandler -Chord "Ctrl+r" -BriefDescription "Runs Atuin search" -ScriptBlock { + RunSearch + } + } + + if ($UpArrow) { + Set-PSReadLineKeyHandler -Chord "UpArrow" -BriefDescription "Runs Atuin search" -ScriptBlock { + $line = $null + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) + + if (!$line.Contains("`n")) { + RunSearch -ExtraArgs "--shell-up-key-binding" + } else { + [Microsoft.PowerShell.PSConsoleReadLine]::PreviousLine() + } + } + } + } + + $ExecutionContext.SessionState.Module.OnRemove += { + $env:ATUIN_SESSION = $null + $Function:PSConsoleHostReadLine = $script:previousPSConsoleHostReadLine + } + + Export-ModuleMember -Function @("Enable-AtuinSearchKeys", "PSConsoleHostReadLine") +} | Import-Module -Global From 21bb83ae489ab3e1ba56326c61f58460b086e191 Mon Sep 17 00:00:00 2001 From: Lucas Trzesniewski Date: Tue, 11 Feb 2025 23:42:16 +0100 Subject: [PATCH 2/2] fix(powershell): work around issue with PowerShell 7.5.0 https://github.com/PowerShell/PowerShell/issues/24986 --- crates/atuin/src/command/client/search.rs | 13 ++++++++-- crates/atuin/src/command/mod.rs | 1 + crates/atuin/src/shell/atuin.ps1 | 30 +++++++++-------------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/atuin/src/command/client/search.rs b/crates/atuin/src/command/client/search.rs index b0e503ad262..506a52c5133 100644 --- a/crates/atuin/src/command/client/search.rs +++ b/crates/atuin/src/command/client/search.rs @@ -1,4 +1,5 @@ -use std::io::{stderr, IsTerminal as _}; +use std::fs::File; +use std::io::{stderr, IsTerminal as _, Write}; use atuin_common::utils::{self, Escapable as _}; use clap::Parser; @@ -119,6 +120,10 @@ pub struct Cmd { /// Set the maximum number of lines Atuin's interface should take up. #[arg(long = "inline-height")] inline_height: Option, + + /// File name to write the result to (hidden from help as this is meant to be used from a script) + #[arg(long = "result-file", hide = true)] + result_file: Option, } impl Cmd { @@ -199,7 +204,11 @@ impl Cmd { if self.interactive { let item = interactive::history(&query, settings, db, &history_store, theme).await?; - if stderr().is_terminal() { + + if let Some(result_file) = self.result_file { + let mut file = File::create(result_file)?; + write!(file, "{item}")?; + } else if stderr().is_terminal() { eprintln!("{}", item.escape_control()); } else { eprintln!("{item}"); diff --git a/crates/atuin/src/command/mod.rs b/crates/atuin/src/command/mod.rs index 09df430ed9e..ec802d38e18 100644 --- a/crates/atuin/src/command/mod.rs +++ b/crates/atuin/src/command/mod.rs @@ -16,6 +16,7 @@ mod gen_completions; #[derive(Subcommand)] #[command(infer_subcommands = true)] +#[allow(clippy::large_enum_variant)] pub enum AtuinCmd { #[cfg(feature = "client")] #[command(flatten)] diff --git a/crates/atuin/src/shell/atuin.ps1 b/crates/atuin/src/shell/atuin.ps1 index 4ae4eb0ad11..143352b41ab 100644 --- a/crates/atuin/src/shell/atuin.ps1 +++ b/crates/atuin/src/shell/atuin.ps1 @@ -63,29 +63,21 @@ New-Module -Name Atuin -ScriptBlock { function RunSearch { param([string]$ExtraArgs = "") - $line = $null - [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) - - # Atuin is started through Start-Process to avoid interfering with the current shell, - # and to capture its output which is provided in stderr (redirected to a temporary file). - - $suggestion = "" + $previousOutputEncoding = [System.Console]::OutputEncoding $resultFile = New-TemporaryFile - try { - $env:ATUIN_SHELL_POWERSHELL = "true" - $argString = "search -i $ExtraArgs -- $line" - Start-Process -Wait -NoNewWindow -RedirectStandardError $resultFile.FullName -FilePath atuin -ArgumentList $argString - $suggestion = (Get-Content -Raw $resultFile -Encoding UTF8 | Out-String).Trim() - } - finally { - $env:ATUIN_SHELL_POWERSHELL = $null - Remove-Item $resultFile - } - $previousOutputEncoding = [System.Console]::OutputEncoding try { [System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $line = $null + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) + + # Atuin is started through Start-Process to avoid interfering with the current shell. + $env:ATUIN_SHELL_POWERSHELL = "true" + $argString = "search -i --result-file ""$resultFile"" $ExtraArgs -- $line" + Start-Process -Wait -NoNewWindow -FilePath atuin -ArgumentList $argString + $suggestion = (Get-Content -Raw $resultFile -Encoding UTF8 | Out-String).Trim() + # PSReadLine maintains its own cursor position, which will no longer be valid if Atuin scrolls the display in inline mode. # Fortunately, InvokePrompt can receive a new Y position and reset the internal state. [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt($null, $Host.UI.RawUI.CursorPosition.Y + [int]$env:ATUIN_POWERSHELL_PROMPT_OFFSET) @@ -108,6 +100,8 @@ New-Module -Name Atuin -ScriptBlock { } finally { [System.Console]::OutputEncoding = $previousOutputEncoding + $env:ATUIN_SHELL_POWERSHELL = $null + Remove-Item $resultFile } }