XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFESystem

【Rust】XFE系统,基于Rust和汇编

公开
关注 0 Fork 0 Star 0
UTF-8
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);

/// <summary>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.</summary>
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<string> history = [];
    private readonly ReadOnlyCollection<string> 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; } = "";
    /// <summary>UTF-16 offset at a scalar boundary, suitable for slicing Input.</summary>
    public int Caret { get; private set; }
    public bool InsertMode { get; private set; } = true;
    public string Prompt => CurrentDirectory + "> ";
    public IReadOnlyList<string> History => historyView;
    public long Revision { get; private set; }

    public TerminalShell(IWorkspaceStorage storage, string version = "0.3.0-dev.4", 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;
    }

    /// <summary>Paste inserts literal text; controls/newlines are rejected so
    /// clipboard content cannot execute commands or inject terminal escapes.</summary>
    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<string> 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<string>(); 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<string> 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 ? "<Dir> " : "      ") + 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)
    {
        if (storage is IWorkspaceBinaryStorage binary)
        {
            byte[] bytes = binary.ReadBytes(source);
            string? text = null;
            try { text = storage.ReadText(source); }
            catch (Exception error) when (error is IOException or DecoderFallbackException) { }
            // Preserve the text model only when it represents the exact source
            // bytes. BOMs and arbitrary binary files must survive unchanged.
            if (text is not null && Encoding.UTF8.GetBytes(text).AsSpan().SequenceEqual(bytes))
                storage.WriteText(destination, text);
            else binary.WriteBytes(destination, bytes);
            return;
        }
        storage.WriteText(destination, storage.ReadText(source));
    }
    private static void Require(IReadOnlyList<string> 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;
    }
}