| 1 | using System.Diagnostics; |
| 2 | using System.Text; |
| 3 | using CascadeIDE.Contracts; |
| 4 | |
| 5 | namespace CascadeIDE.Features.Terminal.DataAcquisition; |
| 6 | |
| 7 | /// <summary> |
| 8 | /// Выбор shell и фабрика PTY-сессии (ConPTY на Windows, redirected fallback). |
| 9 | /// Адаптировано из MIT-примеров AvaloniaTerminal. |
| 10 | /// </summary> |
| 11 | [IoBoundary] |
| 12 | internal static class IntegratedShellLaunch |
| 13 | { |
| 14 | public static string ResolveWorkingDirectory(string? solutionPath) |
| 15 | { |
| 16 | if (string.IsNullOrWhiteSpace(solutionPath)) |
| 17 | return Environment.CurrentDirectory; |
| 18 | try |
| 19 | { |
| 20 | var p = CanonicalFilePath.Normalize(solutionPath.Trim()); |
| 21 | if (File.Exists(p)) |
| 22 | return Path.GetDirectoryName(p) ?? Environment.CurrentDirectory; |
| 23 | if (Directory.Exists(p)) |
| 24 | return p; |
| 25 | } |
| 26 | catch |
| 27 | { |
| 28 | // fall through |
| 29 | } |
| 30 | |
| 31 | return Environment.CurrentDirectory; |
| 32 | } |
| 33 | |
| 34 | public static ShellLaunchConfiguration ResolveLaunchConfiguration(string workingDirectory) |
| 35 | { |
| 36 | foreach (var candidate in EnumerateLaunchCandidates(workingDirectory)) |
| 37 | return candidate; |
| 38 | |
| 39 | var cwd = NormalizeExistingWorkingDirectory(workingDirectory); |
| 40 | return new ShellLaunchConfiguration("echo", ["No shell found"], "echo", cwd); |
| 41 | } |
| 42 | |
| 43 | public static IEnumerable<ShellLaunchConfiguration> EnumerateLaunchCandidates(string workingDirectory) |
| 44 | { |
| 45 | var cwd = NormalizeExistingWorkingDirectory(workingDirectory); |
| 46 | |
| 47 | if (OperatingSystem.IsWindows()) |
| 48 | { |
| 49 | if (ResolvePwshExecutable() is { } pwsh) |
| 50 | yield return new ShellLaunchConfiguration(pwsh, PwshLaunchArguments, "pwsh.exe", cwd); |
| 51 | |
| 52 | var commandPrompt = Environment.GetEnvironmentVariable("ComSpec"); |
| 53 | if (!string.IsNullOrWhiteSpace(commandPrompt) && File.Exists(commandPrompt)) |
| 54 | yield return new ShellLaunchConfiguration(commandPrompt, CmdLaunchArguments, Path.GetFileName(commandPrompt), cwd); |
| 55 | |
| 56 | if (FindExecutableInPath("cmd.exe") is { } cmd) |
| 57 | yield return new ShellLaunchConfiguration(cmd, CmdLaunchArguments, "cmd.exe", cwd); |
| 58 | |
| 59 | yield break; |
| 60 | } |
| 61 | |
| 62 | yield return new ShellLaunchConfiguration("echo", ["No shell found"], "echo", cwd); |
| 63 | } |
| 64 | |
| 65 | /// <summary>UTF-8 для ConPTY + xterm: cmd по умолчанию CP866/OEM, эмулятор ждёт UTF-8.</summary> |
| 66 | internal static void ApplyUtf8ConsoleEnvironment(IDictionary<string, string> environmentVariables) |
| 67 | { |
| 68 | environmentVariables["LANG"] = "ru_RU.UTF-8"; |
| 69 | environmentVariables["LC_ALL"] = "ru_RU.UTF-8"; |
| 70 | environmentVariables["DOTNET_CLI_UI_LANGUAGE"] = "en-US"; |
| 71 | } |
| 72 | |
| 73 | internal static void ApplyUtf8ConsoleEnvironment(System.Collections.Specialized.StringDictionary environment) |
| 74 | { |
| 75 | environment["LANG"] = "ru_RU.UTF-8"; |
| 76 | environment["LC_ALL"] = "ru_RU.UTF-8"; |
| 77 | environment["DOTNET_CLI_UI_LANGUAGE"] = "en-US"; |
| 78 | } |
| 79 | |
| 80 | /// <summary>pwsh 7: UTF-8 по умолчанию; chcp/-Command ломали буфер (BOM, nested host).</summary> |
| 81 | private static readonly string[] PwshLaunchArguments = ["-NoLogo", "-NoExit"]; |
| 82 | |
| 83 | private static readonly string[] CmdLaunchArguments = ["/K", "chcp 65001>nul"]; |
| 84 | |
| 85 | public static IIntegratedShellSession CreateSession(ShellLaunchConfiguration launch) |
| 86 | { |
| 87 | if (OperatingSystem.IsWindows()) |
| 88 | { |
| 89 | try |
| 90 | { |
| 91 | var conPty = new WindowsConPtyIntegratedShellSession(launch); |
| 92 | conPty.Start(); |
| 93 | return conPty; |
| 94 | } |
| 95 | catch (Exception ex) when (ShouldFallbackFromConPty(ex)) |
| 96 | { |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | var redirected = new RedirectedIntegratedShellSession(launch); |
| 101 | redirected.Start(); |
| 102 | return redirected; |
| 103 | } |
| 104 | |
| 105 | internal static string NormalizeExistingWorkingDirectory(string workingDirectory) |
| 106 | { |
| 107 | if (string.IsNullOrWhiteSpace(workingDirectory)) |
| 108 | return Environment.CurrentDirectory; |
| 109 | |
| 110 | try |
| 111 | { |
| 112 | var full = Path.GetFullPath(workingDirectory.Trim()); |
| 113 | if (Directory.Exists(full)) |
| 114 | return full; |
| 115 | } |
| 116 | catch |
| 117 | { |
| 118 | // fall through |
| 119 | } |
| 120 | |
| 121 | return Environment.CurrentDirectory; |
| 122 | } |
| 123 | |
| 124 | public static byte[] NormalizeStandardInput(byte[] input, string? shellDisplayName = null) |
| 125 | { |
| 126 | if (input.Length == 0) |
| 127 | return input; |
| 128 | |
| 129 | input = IntegratedShellStreamSanitizer.StripLeadingUtf8Bom(input).ToArray(); |
| 130 | if (input.Length == 0) |
| 131 | return input; |
| 132 | |
| 133 | var mapDelToBackspace = ShouldMapDelToBackspace(); |
| 134 | var normalized = new List<byte>(input.Length + 4); |
| 135 | var newline = Encoding.UTF8.GetBytes(Environment.NewLine); |
| 136 | |
| 137 | for (var i = 0; i < input.Length; i++) |
| 138 | { |
| 139 | var current = input[i]; |
| 140 | if (current == '\r') |
| 141 | { |
| 142 | normalized.AddRange(newline); |
| 143 | if (i + 1 < input.Length && input[i + 1] == '\n') |
| 144 | i++; |
| 145 | continue; |
| 146 | } |
| 147 | |
| 148 | if (current == '\n') |
| 149 | { |
| 150 | normalized.AddRange(newline); |
| 151 | continue; |
| 152 | } |
| 153 | |
| 154 | // XTerm/AvaloniaTerminal: Backspace → DEL (0x7F). ConPTY + Windows (pwsh PSReadLine, cmd) ждут BS (0x08). |
| 155 | if (mapDelToBackspace && current == 0x7F) |
| 156 | { |
| 157 | normalized.Add(0x08); |
| 158 | continue; |
| 159 | } |
| 160 | |
| 161 | normalized.Add(current); |
| 162 | } |
| 163 | |
| 164 | return normalized.ToArray(); |
| 165 | } |
| 166 | |
| 167 | internal static bool ShouldMapDelToBackspace() => OperatingSystem.IsWindows(); |
| 168 | |
| 169 | internal static bool IsPureBackspaceInput(ReadOnlySpan<byte> input) |
| 170 | { |
| 171 | if (input.IsEmpty) |
| 172 | return false; |
| 173 | |
| 174 | foreach (var b in input) |
| 175 | { |
| 176 | if (b is not (0x7F or 0x08)) |
| 177 | return false; |
| 178 | } |
| 179 | |
| 180 | return true; |
| 181 | } |
| 182 | |
| 183 | internal static bool ShouldApplyResize((int cols, int rows)? lastResize, int cols, int rows, out (int cols, int rows) normalized) |
| 184 | { |
| 185 | normalized = (Math.Max(cols, 1), Math.Max(rows, 1)); |
| 186 | return lastResize != normalized; |
| 187 | } |
| 188 | |
| 189 | private static bool ShouldFallbackFromConPty(Exception ex) => |
| 190 | ex is PlatformNotSupportedException |
| 191 | or EntryPointNotFoundException |
| 192 | or DllNotFoundException |
| 193 | or InvalidOperationException |
| 194 | or System.ComponentModel.Win32Exception |
| 195 | or FileNotFoundException; |
| 196 | |
| 197 | private static string? ResolvePwshExecutable() |
| 198 | { |
| 199 | if (FindExecutableInPath("pwsh.exe") is { } fromPath) |
| 200 | return fromPath; |
| 201 | |
| 202 | foreach (var candidate in PwshWellKnownPaths) |
| 203 | { |
| 204 | if (File.Exists(candidate)) |
| 205 | return candidate; |
| 206 | } |
| 207 | |
| 208 | return null; |
| 209 | } |
| 210 | |
| 211 | private static readonly string[] PwshWellKnownPaths = |
| 212 | [ |
| 213 | @"C:\Program Files\PowerShell\7\pwsh.exe", |
| 214 | @"C:\Program Files\PowerShell\7-preview\pwsh.exe", |
| 215 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerShell", "7", "pwsh.exe"), |
| 216 | ]; |
| 217 | |
| 218 | private static string? FindExecutableInPath(string command) |
| 219 | { |
| 220 | if (Path.IsPathRooted(command) |
| 221 | || command.Contains(Path.DirectorySeparatorChar) |
| 222 | || command.Contains(Path.AltDirectorySeparatorChar)) |
| 223 | { |
| 224 | return File.Exists(command) ? command : null; |
| 225 | } |
| 226 | |
| 227 | var path = Environment.GetEnvironmentVariable("PATH"); |
| 228 | if (string.IsNullOrWhiteSpace(path)) |
| 229 | return null; |
| 230 | |
| 231 | IEnumerable<string> extensions = [string.Empty]; |
| 232 | if (OperatingSystem.IsWindows()) |
| 233 | { |
| 234 | var pathExt = Environment.GetEnvironmentVariable("PATHEXT"); |
| 235 | if (!string.IsNullOrWhiteSpace(pathExt)) |
| 236 | { |
| 237 | extensions = pathExt.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) |
| 242 | { |
| 243 | foreach (var extension in extensions) |
| 244 | { |
| 245 | var candidate = Path.Combine( |
| 246 | directory, |
| 247 | command.EndsWith(extension, StringComparison.OrdinalIgnoreCase) ? command : command + extension); |
| 248 | if (File.Exists(candidate)) |
| 249 | return candidate; |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | return null; |
| 254 | } |
| 255 | } |
| 256 | |