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.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;
}
/// <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)
{
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<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;
}
}
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.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;
}
/// <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)
{
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<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;
}
}