Forge
csharpdeeb25a2
1using System.Diagnostics;
2using System.Text;
3using CascadeIDE.Contracts;
4
5namespace CascadeIDE.Features.Terminal.DataAcquisition;
6
7/// <summary>
8/// Выбор shell и фабрика PTY-сессии (ConPTY на Windows, redirected fallback).
9/// Адаптировано из MIT-примеров AvaloniaTerminal.
10/// </summary>
11[IoBoundary]
12internal 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
View only · write via MCP/CIDE