| 1 | using AvaloniaTerminal; |
| 2 | using CascadeIDE.Features.Terminal.DataAcquisition; |
| 3 | using CascadeIDE.Services; |
| 4 | using CascadeIDE.ViewModels; |
| 5 | |
| 6 | namespace CascadeIDE.Features.Terminal; |
| 7 | |
| 8 | /// <summary> |
| 9 | /// Вкладка «Terminal» нижней панели: интерактивная shell-сессия (ConPTY на Windows, redirected fallback) |
| 10 | /// через <see cref="TerminalControlModel"/> (ANSI, сетка, сырой TTY-ввод). |
| 11 | /// </summary> |
| 12 | public sealed class TerminalPanelViewModel : ViewModelBase, IDisposable |
| 13 | { |
| 14 | private const int ScrollbackLines = 10_000; |
| 15 | private static readonly TimeSpan BackspaceRepeatMinInterval = TimeSpan.FromMilliseconds(50); |
| 16 | |
| 17 | private readonly IntegratedTerminalSessionHost _shellHost; |
| 18 | private readonly IntegratedShellBackspaceBurstGuard _backspaceBurstGuard = new(); |
| 19 | private bool _disposed; |
| 20 | private bool _sessionStarted; |
| 21 | private (int cols, int rows)? _pendingTerminalSize; |
| 22 | private DateTime _lastPureBackspaceSentUtc = DateTime.MinValue; |
| 23 | private bool _shellOutputLeadingBomStripped; |
| 24 | |
| 25 | public TerminalPanelViewModel(Func<string?> getSolutionPath) |
| 26 | { |
| 27 | _shellHost = new IntegratedTerminalSessionHost(getSolutionPath); |
| 28 | TerminalModel = new TerminalControlModel(new TerminalOptions |
| 29 | { |
| 30 | ReflowOnResize = false, |
| 31 | Scrollback = ScrollbackLines, |
| 32 | }); |
| 33 | |
| 34 | TerminalModel.UserInput += OnTerminalUserInput; |
| 35 | TerminalModel.SizeChanged += OnTerminalSizeChanged; |
| 36 | _shellHost.OutputReceived += OnShellOutput; |
| 37 | _shellHost.SessionExited += OnShellSessionExited; |
| 38 | } |
| 39 | |
| 40 | public TerminalControlModel TerminalModel { get; } |
| 41 | |
| 42 | public void EnsureSessionStarted() |
| 43 | { |
| 44 | if (_disposed || _sessionStarted) |
| 45 | return; |
| 46 | |
| 47 | try |
| 48 | { |
| 49 | _shellHost.EnsureStarted(); |
| 50 | _sessionStarted = true; |
| 51 | _backspaceBurstGuard.Reset(); |
| 52 | _shellOutputLeadingBomStripped = false; |
| 53 | ApplyPendingResize(); |
| 54 | } |
| 55 | catch (Exception ex) |
| 56 | { |
| 57 | FeedOnUiThread(ex.Message + "\r\n"); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | public void Clear() => FeedOnUiThread("\x1b[2J\x1b[H"); |
| 62 | |
| 63 | public void AppendOutput(string text) |
| 64 | { |
| 65 | if (string.IsNullOrEmpty(text)) |
| 66 | return; |
| 67 | |
| 68 | FeedOnUiThread(text); |
| 69 | } |
| 70 | |
| 71 | private void OnTerminalUserInput(object? sender, TerminalUserInputEventArgs e) |
| 72 | { |
| 73 | if (_disposed || e.Data.Length == 0) |
| 74 | return; |
| 75 | |
| 76 | var rawInput = e.Data.ToArray(); |
| 77 | if (IntegratedShellLaunch.IsPureBackspaceInput(rawInput)) |
| 78 | { |
| 79 | var now = DateTime.UtcNow; |
| 80 | if (now - _lastPureBackspaceSentUtc < BackspaceRepeatMinInterval) |
| 81 | return; |
| 82 | |
| 83 | _lastPureBackspaceSentUtc = now; |
| 84 | } |
| 85 | |
| 86 | var filteredInput = _backspaceBurstGuard.FilterUserInput(rawInput); |
| 87 | if (filteredInput.Length == 0) |
| 88 | return; |
| 89 | |
| 90 | try |
| 91 | { |
| 92 | EnsureSessionStarted(); |
| 93 | _shellHost.SendInput(filteredInput); |
| 94 | } |
| 95 | catch (Exception ex) |
| 96 | { |
| 97 | FeedOnUiThread(ex.Message + "\r\n"); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | private void OnTerminalSizeChanged(object? sender, TerminalSizeChangedEventArgs e) |
| 102 | { |
| 103 | if (_disposed || e.Cols <= 0 || e.Rows <= 0) |
| 104 | return; |
| 105 | |
| 106 | _pendingTerminalSize = (e.Cols, e.Rows); |
| 107 | if (_sessionStarted) |
| 108 | _shellHost.Resize(e.Cols, e.Rows); |
| 109 | } |
| 110 | |
| 111 | private void OnShellOutput(byte[] data) |
| 112 | { |
| 113 | if (data.Length == 0) |
| 114 | return; |
| 115 | |
| 116 | _backspaceBurstGuard.NotifyShellOutput(); |
| 117 | var sanitized = IntegratedShellStreamSanitizer.SanitizeShellOutput(data, ref _shellOutputLeadingBomStripped); |
| 118 | if (sanitized.Length == 0) |
| 119 | return; |
| 120 | |
| 121 | if (UiScheduler.Default.CheckAccess()) |
| 122 | { |
| 123 | TerminalModel.Feed(sanitized, sanitized.Length); |
| 124 | return; |
| 125 | } |
| 126 | |
| 127 | UiScheduler.Default.Post(() => |
| 128 | { |
| 129 | if (_disposed) |
| 130 | return; |
| 131 | |
| 132 | TerminalModel.Feed(sanitized, sanitized.Length); |
| 133 | }); |
| 134 | } |
| 135 | |
| 136 | private void OnShellSessionExited(int exitCode) |
| 137 | { |
| 138 | _sessionStarted = false; |
| 139 | _backspaceBurstGuard.Reset(); |
| 140 | if (exitCode != 0) |
| 141 | FeedOnUiThread($"\r\nShell exited: {exitCode}\r\n"); |
| 142 | } |
| 143 | |
| 144 | private void ApplyPendingResize() |
| 145 | { |
| 146 | if (_pendingTerminalSize is not { } size) |
| 147 | return; |
| 148 | |
| 149 | _shellHost.Resize(size.cols, size.rows); |
| 150 | } |
| 151 | |
| 152 | private void FeedOnUiThread(string text) |
| 153 | { |
| 154 | if (string.IsNullOrEmpty(text)) |
| 155 | return; |
| 156 | |
| 157 | if (UiScheduler.Default.CheckAccess()) |
| 158 | { |
| 159 | TerminalModel.Feed(text); |
| 160 | return; |
| 161 | } |
| 162 | |
| 163 | UiScheduler.Default.Post(() => |
| 164 | { |
| 165 | if (_disposed) |
| 166 | return; |
| 167 | |
| 168 | TerminalModel.Feed(text); |
| 169 | }); |
| 170 | } |
| 171 | |
| 172 | public void Dispose() |
| 173 | { |
| 174 | if (_disposed) |
| 175 | return; |
| 176 | |
| 177 | _disposed = true; |
| 178 | TerminalModel.UserInput -= OnTerminalUserInput; |
| 179 | TerminalModel.SizeChanged -= OnTerminalSizeChanged; |
| 180 | _shellHost.OutputReceived -= OnShellOutput; |
| 181 | _shellHost.SessionExited -= OnShellSessionExited; |
| 182 | _shellHost.Dispose(); |
| 183 | } |
| 184 | } |
| 185 | |