using System.Collections.ObjectModel; using System.Text; using XFESystem.Console; using XFESystem.Core; namespace XFESystem.Desktop; public enum TerminalShellKey { Character, Enter, Backspace, Delete, Left, Right, Up, Down, Home, End, Insert, Cancel } public sealed record TerminalShellCommandResult(int ExitCode, bool CloseRequested = false, string? OpenPath = null, string? ManagedProgramPath = null); /// One bounded RAM shell session. The UI owns input; application output /// uses the existing ANSI terminal writer. Input is rendered beside Prompt and /// never reparses or rewrites the completed terminal output on each keystroke. public sealed class TerminalShell { public const int MaximumInputCharacters = 1024, MaximumHistoryEntries = 64, MaximumCommandOutputCharacters = 16_384; private readonly IWorkspaceStorage storage; private readonly TerminalBuffer output; private readonly List history = []; private readonly ReadOnlyCollection historyView; private int historyIndex; private string historyDraft = ""; public TerminalTextWriter ProgramOutput { get; } public string Version { get; } public string CurrentDirectory { get; private set; } = SystemPaths.Documents; public string Input { get; private set; } = ""; /// UTF-16 offset at a scalar boundary, suitable for slicing Input. public int Caret { get; private set; } public bool InsertMode { get; private set; } = true; public string Prompt => CurrentDirectory + "> "; public IReadOnlyList History => historyView; public long Revision { get; private set; } public TerminalShell(IWorkspaceStorage storage, string version = "0.3.0-dev.3", int columns = 100, int rows = 30) { this.storage = storage ?? throw new ArgumentNullException(nameof(storage)); if (string.IsNullOrWhiteSpace(version) || version.Any(char.IsControl)) throw new ArgumentException("Invalid version.", nameof(version)); Version = version; historyView = history.AsReadOnly(); output = new(columns, rows, scrollbackLimit: 256); ProgramOutput = new(output); ProgramOutput.WriteLine("XFESystem|XFEstudio"); ProgramOutput.WriteLine(version); ProgramOutput.WriteLine("输入 help 查询帮助指令 / Type help for commands."); } public TerminalSnapshot ReadOutput() => ProgramOutput.Snapshot(); public void MoveCaret(int index) { Caret = Math.Clamp(index, 0, Input.Length); if (Caret > 0 && Caret < Input.Length && char.IsLowSurrogate(Input[Caret]) && char.IsHighSurrogate(Input[Caret - 1])) Caret--; Revision++; } public TerminalShellCommandResult? HandleKey(TerminalShellKey key, string? text = null) { switch (key) { case TerminalShellKey.Character: InsertText(text ?? ""); break; case TerminalShellKey.Enter: return Submit(); case TerminalShellKey.Left: Caret = Previous(Input, Caret); break; case TerminalShellKey.Right: Caret = Next(Input, Caret); break; case TerminalShellKey.Home: Caret = 0; break; case TerminalShellKey.End: Caret = Input.Length; break; case TerminalShellKey.Insert: InsertMode = !InsertMode; break; case TerminalShellKey.Backspace: if (Caret > 0) { int start = Previous(Input, Caret); Input = Input.Remove(start, Caret - start); Caret = start; } break; case TerminalShellKey.Delete: if (Caret < Input.Length) Input = Input.Remove(Caret, Next(Input, Caret) - Caret); break; case TerminalShellKey.Up: Recall(-1); break; case TerminalShellKey.Down: Recall(1); break; case TerminalShellKey.Cancel: StartCommandLine(); ProgramOutput.WriteLine(Prompt + Input + "^C"); ResetInput(); break; } Revision++; return null; } /// Paste inserts literal text; controls/newlines are rejected so /// clipboard content cannot execute commands or inject terminal escapes. public bool InsertText(string text) { ArgumentNullException.ThrowIfNull(text); if (text.Length > MaximumInputCharacters || text.Any(char.IsControl) || !WellFormed(text)) return false; string result = Input; int cursor = Caret; for (int index = 0; index < text.Length;) { int next = Next(text, index), length = next - index; int remove = !InsertMode && cursor < result.Length ? Next(result, cursor) - cursor : 0; if (result.Length - remove + length > MaximumInputCharacters) return false; result = result.Remove(cursor, remove).Insert(cursor, text[index..next]); cursor += length; index = next; } Input = result; Caret = cursor; Revision++; return true; } public TerminalShellCommandResult Submit() { string command = Input; StartCommandLine(); ProgramOutput.WriteLine(Prompt + command); ResetInput(); if (!string.IsNullOrWhiteSpace(command) && (history.Count == 0 || history[^1] != command)) { if (history.Count == MaximumHistoryEntries) history.RemoveAt(0); history.Add(command); } historyIndex = history.Count; TerminalShellCommandResult result; try { result = Execute(ParseCommandLine(command)); } catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) { ProgramOutput.WriteLine("Error: " + error.Message); result = new(1); } Revision++; return result; } private void ResetInput() { Input = ""; Caret = 0; historyIndex = history.Count; historyDraft = ""; } private void StartCommandLine() { // The desktop hands writer ownership back only after completion. This // UI-owned check also covers Console.Write("progress\\r") at column 0. bool occupied = output.CursorColumn != 0; for (int col = 0; !occupied && col < output.Columns; col++) { var cell = output.GetCell(col, output.CursorRow); occupied = !string.IsNullOrWhiteSpace(cell.Text) || cell.Style.DisplayBackground != TerminalColor.ThemeBackground; } if (occupied) ProgramOutput.WriteLine(); } private void Recall(int direction) { if (history.Count == 0) return; if (historyIndex == history.Count && direction < 0) historyDraft = Input; historyIndex = Math.Clamp(historyIndex + direction, 0, history.Count); Input = historyIndex == history.Count ? historyDraft : history[historyIndex]; Caret = Input.Length; } public static IReadOnlyList ParseCommandLine(string command) { ArgumentNullException.ThrowIfNull(command); if (command.Length > MaximumInputCharacters || command.Any(char.IsControl)) throw new ArgumentException("Command input is too long or contains controls."); var tokens = new List(); var token = new StringBuilder(); char quote = '\0'; bool started = false; foreach (char character in command) { if (quote != '\0') { if (character == quote) quote = '\0'; else token.Append(character); started = true; } else if (character is '\'' or '"') { quote = character; started = true; } else if (char.IsWhiteSpace(character)) { if (started) { tokens.Add(token.ToString()); token.Clear(); started = false; } if (tokens.Count > 128) throw new ArgumentException("Too many command arguments."); } else { token.Append(character); started = true; } } if (quote != '\0') throw new ArgumentException("Unclosed quote."); if (started) tokens.Add(token.ToString()); if (tokens.Count > 128) throw new ArgumentException("Too many command arguments."); return tokens; } public string ResolvePath(string path) { ArgumentException.ThrowIfNullOrWhiteSpace(path); if (path.Length > 1024) throw new ArgumentException("The path is too long.", nameof(path)); path = path.Replace('/', '\\'); string current; if (path.Length >= 2 && path[1] == ':') { if (char.ToUpperInvariant(path[0]) != 'X' || path.Length < 3 || path[2] != '\\') throw new ArgumentException("Only absolute X:\\ paths are supported."); current = SystemPaths.Root; path = path[3..]; } else if (path.StartsWith('\\')) { current = SystemPaths.Root; path = path.TrimStart('\\'); } else current = CurrentDirectory; foreach (string part in path.Split('\\', StringSplitOptions.RemoveEmptyEntries)) if (part == "..") current = SystemPaths.Parent(current); else if (part != ".") current = SystemPaths.Combine(current, part); return SystemPaths.Normalize(current); } private TerminalShellCommandResult Execute(IReadOnlyList args) { if (args.Count == 0) return new(0); string command = args[0].ToLowerInvariant(); switch (command) { case "help": ProgramOutput.WriteLine("help | cls | ver | pwd | cd [Path] | ls [Path] | file [Path]"); ProgramOutput.WriteLine("md Path | mf Path | cat Path | write Path Text | append Path Text"); ProgramOutput.WriteLine("cp Source Destination | mv Source Destination | del Path"); ProgramOutput.WriteLine("echo Text | history | open Path | run Path | exit"); ProgramOutput.WriteLine("Up/Down: history; Left/Right/Home/End: cursor; Insert: overwrite; Ctrl+C: cancel."); ProgramOutput.WriteLine(storage.IsPersistent ? "Storage: persistent workspace" : "Storage: RAM session; shutdown discards files."); break; case "cls": Require(args, 1, "cls"); ProgramOutput.Write("\u001bc"); break; case "ver": Require(args, 1, "ver"); ProgramOutput.WriteLine("XFESystem " + Version); break; case "pwd": Require(args, 1, "pwd"); ProgramOutput.WriteLine(CurrentDirectory); break; case "echo": Print(string.Join(" ", args.Skip(1))); break; case "history": for (int index = 0; index < history.Count; index++) ProgramOutput.WriteLine((index + 1) + " " + history[index]); break; case "cd": if (args.Count == 1) ProgramOutput.WriteLine(CurrentDirectory); else { Require(args, 2, "cd Path"); string target = ResolvePath(args[1]); _ = storage.List(target); CurrentDirectory = target; } break; case "ls": case "dir": if (args.Count > 2) throw new ArgumentException("Usage: ls [Path]"); foreach (var entry in storage.List(args.Count == 2 ? ResolvePath(args[1]) : CurrentDirectory)) ProgramOutput.WriteLine((entry.IsDirectory ? " " : " ") + entry.Name + (entry.IsDirectory ? "" : " " + entry.Length + " bytes/chars")); break; case "file": if (args.Count == 1) return Execute(["ls"]); Require(args, 2, "file [Path]"); string inspected = ResolvePath(args[1]); if (inspected == SystemPaths.Root) ProgramOutput.WriteLine(inspected + " Directory"); else { var entries = storage.List(SystemPaths.Parent(inspected)); int index = Array.FindIndex(entries, entry => entry.Path.Equals(inspected, StringComparison.OrdinalIgnoreCase)); if (index < 0) throw new FileNotFoundException("Path not found.", inspected); var entry = entries[index]; ProgramOutput.WriteLine(entry.Path + (entry.IsDirectory ? " Directory" : " File " + entry.Length + " bytes/chars")); } break; case "md": Require(args, 2, "md Path"); storage.CreateDirectory(ResolvePath(args[1])); break; case "mf": Require(args, 2, "mf Path"); string created = ResolvePath(args[1]); if (storage.Exists(created)) _ = storage.ReadText(created); else storage.WriteText(created, ""); break; case "cat": case "type": Require(args, 2, "cat Path"); Print(storage.ReadText(ResolvePath(args[1]))); break; case "write": case "append": if (args.Count < 3) throw new ArgumentException("Usage: " + command + " Path Text"); string path = ResolvePath(args[1]), value = string.Join(" ", args.Skip(2)); storage.WriteText(path, command == "append" && storage.Exists(path) ? storage.ReadText(path) + value : value); break; case "del": case "rm": Require(args, 2, "del Path"); storage.Delete(ResolvePath(args[1])); break; case "mv": Require(args, 3, "mv Source Destination"); storage.Move(ResolvePath(args[1]), ResolvePath(args[2])); break; case "cp": Require(args, 3, "cp Source Destination"); string source = ResolvePath(args[1]), destination = ResolvePath(args[2]); if (storage.Exists(destination)) throw new IOException("The destination already exists."); CopyFile(source, destination); break; case "open": Require(args, 2, "open Path"); return new(0, OpenPath: ExistingPath(args[1])); case "run": Require(args, 2, "run Path"); return new(0, ManagedProgramPath: ExistingPath(args[1])); case "exit": Require(args, 1, "exit"); return new(0, CloseRequested: true); default: ProgramOutput.WriteLine("Unknown command: " + args[0] + ". Type help."); return new(127); } return new(0); } private string ExistingPath(string path) { path = ResolvePath(path); if (!storage.Exists(path)) throw new FileNotFoundException("Path not found.", path); return path; } private void CopyFile(string source, string destination) { string text; try { text = storage.ReadText(source); } catch (IOException) when (storage is IWorkspaceBinaryStorage) { var binary = (IWorkspaceBinaryStorage)storage; binary.WriteBytes(destination, binary.ReadBytes(source)); return; } // Keep text files editable as text. Only the source read is probed; // a destination/quota error must not retry through another storage API. storage.WriteText(destination, text); } private static void Require(IReadOnlyList args, int count, string usage) { if (args.Count != count) throw new ArgumentException("Usage: " + usage); } private void Print(string text) { string displayed = text.Length > MaximumCommandOutputCharacters ? text[..MaximumCommandOutputCharacters] : text; // Shell text files use logical LF/CRLF lines. Normalize only this // bounded display copy; file bytes and raw program VT writes remain intact. ProgramOutput.Write(displayed.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\n", "\r\n", StringComparison.Ordinal)); ProgramOutput.Complete(); // A cut/malformed ANSI sequence cannot swallow the next prompt. ProgramOutput.WriteLine(); if (text.Length > MaximumCommandOutputCharacters) ProgramOutput.WriteLine("[Output truncated at " + MaximumCommandOutputCharacters + " characters]"); } private static int Previous(string text, int cursor) => cursor <= 0 ? 0 : cursor >= 2 && char.IsLowSurrogate(text[cursor - 1]) && char.IsHighSurrogate(text[cursor - 2]) ? cursor - 2 : cursor - 1; private static int Next(string text, int cursor) => cursor >= text.Length ? text.Length : char.IsHighSurrogate(text[cursor]) && cursor + 1 < text.Length && char.IsLowSurrogate(text[cursor + 1]) ? cursor + 2 : cursor + 1; private static bool WellFormed(string text) { for (int index = 0; index < text.Length; index++) if (char.IsHighSurrogate(text[index])) { if (++index >= text.Length || !char.IsLowSurrogate(text[index])) return false; } else if (char.IsLowSurrogate(text[index])) return false; return true; } }