diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3aaa5..e69ce7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ All notable changes to Open Stus are recorded here. The format follows ### Added +- **Archives open in the panel.** Enter (or `Ctrl+PgDn`) on a zip - and its relatives jar, war, + nupkg, vsix, whl, apk - a tar, a `.tar.gz` / `.tgz` or a lone `.gz` walks into it as if it were a + folder: the title and the prompt show `...\tools.zip\bin`, `..` climbs back out onto the archive, + F3 views a file, Enter runs one, `Ctrl+L` sizes a folder, and F5 copies files and folders out with + the ordinary copy's destination rules, overwrite questions and progress. Archives are read-only: + F4, F6, F7, F8 and copying into one say so instead of acting. A path inside an archive also works + from the folder history, the saved tabs and the command line (`stus C:\x\tools.zip`). Entry + names that climb out with `..` or start from a root are never extracted. 7z, rar, cab and iso + still open with the program the system associates with them. + +- **Selecting on the command line while the panels are hidden.** After `Ctrl+O`, Shift+Left / + Shift+Right, Shift+Home / Shift+End and the Ctrl+Shift word forms select text, `Ctrl+A` selects the + line, `Ctrl+C` or `Ctrl+Ins` copy the selection, `Shift+Del` cuts it, Backspace and Del remove it, + and typing or pasting replaces it. With the panels up those chords stay the panel's. + +- **Colour for files of unknown type in the editor.** A file no language claims - a log, an ini, a + `.txt`, a name with no extension - gets its numbers (dates, times, versions and addresses + included), closed quoted strings, URLs, `#` / `;` / `//` comment lines, `[section]` headers and + words such as `true`, `null`, `ERROR` and `WARN` coloured. F5 in the editor switches the colouring + off and on again; the key bar shows which way it will go. + +- **More file-type colours.** Backups and temporaries (`*.bak`, `*.tmp`, `*.old`, `*.orig`, + `*.swp`, `*.$$$`, `*~`, `~$*` and the like) are brown and media (images, sound, video) light red; + both are theme entries, `PanelTemporary` and `PanelMedia`. More archive extensions (`tgz`, `jar`, + `nupkg`, `zst`, `deb`, `rpm` and others) take the archive colour. + - **A startup notice on the user screen.** Before the panels come up, the program prints a text portrait of Vasyl Stus beside the version, the licence and the credit for the photograph, so `Ctrl+O` reveals it underneath the panels the way it reveals command output. The portrait is @@ -151,6 +177,11 @@ the four reported issues at the top of this list. ### Changed +- **The drive menu lines the panels up.** Picking, with `Alt+F1` / `Alt+F2`, the drive the other + panel is on opens the other panel's folder rather than the drive's root. +- **The editor's caret is the console's own cursor.** It is no longer an inverted cell drawn into + the text but the terminal's blinking vertical bar - a blinking block while Insert has switched to + overwriting - and the terminal's own cursor shape is put back when the editor closes. - **Renamed to Open Stus.** The solution, the projects, the namespaces and the settings folder are `OpenStus`, and the executable is `stus` (it was `oc`). The name honours the poet Vasyl Stus; the README says why. Settings and history now live under `OpenStus` in the profile folder. diff --git a/README.md b/README.md index c214370..d87281a 100644 --- a/README.md +++ b/README.md @@ -274,8 +274,8 @@ generated from `HelpScreen.Bindings`, so they cannot drift apart. | `Left / Right` | Move one column, or edit the command line | | `PgUp / PgDn` | Scroll one page | | `Home / End` | First / last item | -| `Enter` | Enter a folder, or run the file under the cursor | -| `Ctrl+PgDn` | Enter the folder under the cursor | +| `Enter` | Enter a folder or an archive, or run the file under the cursor | +| `Ctrl+PgDn` | Enter the folder or archive under the cursor | | `Ctrl+PgUp` | Go to the parent folder | | `Ctrl+\` | Go to the root of the current drive | | `Tab` | Switch the active panel | @@ -286,7 +286,7 @@ generated from `HelpScreen.Bindings`, so they cannot drift apart. | `Ctrl+F1 / Ctrl+F2` | Hide or show the left / right panel | | `Ctrl+H` | Show or hide hidden and system files | | `Ctrl+B` | Show or hide the function key bar | -| `Alt+F1 / Alt+F2` | Change the drive of the left / right panel | +| `Alt+F1 / Alt+F2` | Change the drive of the left / right panel; the other panel's folder when it is on that drive | | `Alt+` | Quick search by name | | `Ctrl+T` | Open a new tab on the current folder | | `Ctrl+W` | Close the current tab | @@ -384,6 +384,7 @@ generated from `HelpScreen.Bindings`, so they cannot drift apart. | `Shift+arrows` | Select text | | `Ctrl+Y` | Delete the current line | | `F2` | Save | +| `F5` | Switch syntax colouring off or on | | `F7` | Search | | `Shift+F7` | Search again | | `F10 / Esc` | Close the editor | @@ -402,6 +403,8 @@ generated from `HelpScreen.Bindings`, so they cannot drift apart. | `Right / End` | Accept the grey suggestion after the caret | | `Tab` | Complete the file name under the caret; a list when several match | | `Ctrl+Left / Ctrl+Right` | Move one word | +| `Shift+Left / Shift+Right` | Select text while the panels are hidden (Ctrl+O); Shift+Home / End and Ctrl+Shift+arrows too | +| `Ctrl+C / Ctrl+Ins` | Copy the selected text; Shift+Del cuts it | | `Ctrl+Backspace / Ctrl+Del` | Delete one word | | `Ctrl+Enter / Ctrl+J` | Insert the name under the cursor | | `Ctrl+F` | Insert the full name under the cursor | @@ -459,6 +462,7 @@ src/OpenStus/ Input/ KeyEvent, MouseEvent and the Windows / portable input backends Theming/ the Theme palette and its JSON serialisation Files/ FileEntry, directory reading, sorting, masks, size formatting, drives + Archives/ reading and extracting zip and tar archives the panels walk into Panels/ FilePanel, the view modes, the column layout, quick search, folder history Ui/ dialogs, controls, popup and horizontal menus, key bar, command line, clock, help Viewer/ the F3 file viewer and its lazily indexed model @@ -498,8 +502,9 @@ the only type that wires the whole thing together. ## Roadmap -- **Archives** — browse a `.zip` or `.7z` as if it were a folder, plus Shift+F1 - and Shift+F2 to add and extract. +- **Archives** — `.7z` and `.rar` browsing, and writing into archives: Shift+F1 + and Shift+F2 to add and extract. Zip and tar archives can already be browsed, + viewed and copied out of. - **Plugins** — a real plugin surface behind F11, so a panel can be backed by something other than the file system. - **Tree panel** and **quick view** (Ctrl+Q). diff --git a/src/OpenStus/Archives/ArchiveFile.cs b/src/OpenStus/Archives/ArchiveFile.cs new file mode 100644 index 0000000..6eb4185 --- /dev/null +++ b/src/OpenStus/Archives/ArchiveFile.cs @@ -0,0 +1,773 @@ +using System.Formats.Tar; +using System.IO.Compression; +using OpenStus.Operations; + +namespace OpenStus.Archives; + +/// The archive formats the panel can walk into. +public enum ArchiveKind +{ + /// A zip file, and the formats built on it: jar, nupkg, vsix, whl and friends. + Zip, + + /// An uncompressed tar file. + Tar, + + /// A gzip-compressed tar file (.tar.gz, .tgz). + TarGzip, + + /// A single gzip-compressed file (.gz that is not a tar). + Gzip, +} + +/// One file or folder inside an archive. +public sealed class ArchiveItem +{ + /// + /// The path inside the archive: / separated, with no leading or trailing separator - + /// "docs/images/logo.png". + /// + public required string Path { get; init; } + + /// The last segment of . + public string Name => ArchiveFile.LeafOf(Path); + + /// Whether this is a folder. + public bool IsDirectory { get; init; } + + /// The uncompressed size in bytes; zero for a folder. + public long Size { get; init; } + + /// The modification time, local. + public DateTime Modified { get; init; } + + /// The DOS attributes the archive recorded, when it recorded any. + public FileAttributes Attributes { get; init; } +} + +/// +/// The catalogue of one archive - every file and folder in it - plus extraction. Read-only: the +/// panel browses an archive and copies out of it, but never writes into one. +/// +/// +/// +/// Only formats the runtime reads natively are supported: zip and its relatives, tar, gzip'd tar +/// and a lone gzip'd file. Anything else - 7z, rar, cab, iso - is left to the program the system +/// associates with it. +/// +/// +/// Entry names are sanitised before anything else sees them: backslashes become slashes, empty +/// and . segments go, and an entry that climbs out with .. or starts from a drive or +/// a root is dropped entirely, so nothing extracted can ever land outside the chosen folder. +/// Folders the archive only implies (a file a/b/c.txt with no a/ entry) are +/// synthesised, so every level can be walked into. +/// +/// +public sealed class ArchiveFile +{ + private const int CopyBlock = 81920; + private const int ReportIntervalMs = 33; + + private static readonly Dictionary KindByExtension = new(StringComparer.OrdinalIgnoreCase) + { + [".zip"] = ArchiveKind.Zip, + [".jar"] = ArchiveKind.Zip, + [".war"] = ArchiveKind.Zip, + [".ear"] = ArchiveKind.Zip, + [".nupkg"] = ArchiveKind.Zip, + [".snupkg"] = ArchiveKind.Zip, + [".vsix"] = ArchiveKind.Zip, + [".whl"] = ArchiveKind.Zip, + [".xpi"] = ArchiveKind.Zip, + [".apk"] = ArchiveKind.Zip, + [".tar"] = ArchiveKind.Tar, + [".tgz"] = ArchiveKind.TarGzip, + [".gz"] = ArchiveKind.Gzip, + }; + + private readonly Dictionary _items = new(StringComparer.Ordinal); + private readonly Dictionary> _children = new(StringComparer.Ordinal); + + private ArchiveFile(string filePath, ArchiveKind kind, long length, DateTime stamp) + { + FilePath = filePath; + Kind = kind; + FileLength = length; + FileStamp = stamp; + _children[string.Empty] = []; + } + + /// The archive file on disk. + public string FilePath { get; } + + /// The format. + public ArchiveKind Kind { get; } + + /// The archive's size when the catalogue was read. + public long FileLength { get; } + + /// The archive's modification time when the catalogue was read. + public DateTime FileStamp { get; } + + /// Every file and folder, synthesised folders included. + public IReadOnlyCollection Items => _items.Values; + + /// The format a file name says it is, or for anything unsupported. + /// The file path or bare name. + /// The format, or . + public static ArchiveKind? KindOf(string? path) + { + if (string.IsNullOrEmpty(path)) + { + return null; + } + + string name = System.IO.Path.GetFileName(path); + if (name.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + return ArchiveKind.TarGzip; + } + + string extension = System.IO.Path.GetExtension(name); + return KindByExtension.TryGetValue(extension, out ArchiveKind kind) ? kind : null; + } + + /// Whether a file name is one of the formats the panel can walk into. + /// The file path or bare name. + /// for a supported archive. + public static bool CanOpen(string? path) => KindOf(path) is not null; + + /// Reads the catalogue of an archive. + /// The archive file. + /// The catalogue. + /// The file could not be read. + /// The file is not a valid archive of its kind. + /// The file name is not a supported archive. + public static ArchiveFile Open(string path) + { + ArgumentNullException.ThrowIfNull(path); + + ArchiveKind kind = KindOf(path) ?? throw new NotSupportedException("Not a supported archive: " + path); + var info = new FileInfo(path); + var archive = new ArchiveFile(info.FullName, kind, info.Length, info.LastWriteTime); + + switch (kind) + { + case ArchiveKind.Zip: + archive.ReadZipCatalogue(); + break; + + case ArchiveKind.Tar: + case ArchiveKind.TarGzip: + archive.ReadTarCatalogue(); + break; + + default: + archive.ReadGzipCatalogue(info); + break; + } + + return archive; + } + + /// + /// Whether the file on disk is still the one the catalogue was read from - same size, same + /// time stamp - so a panel re-read can skip decompressing a big tarball that has not changed. + /// + /// when the catalogue is still current. + public bool IsCurrent() + { + try + { + var info = new FileInfo(FilePath); + return info.Exists && info.Length == FileLength && info.LastWriteTime == FileStamp; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return false; + } + } + + /// The item at an inner path, or . + /// The / separated path inside the archive. + /// The item, or . + public ArchiveItem? Find(string innerPath) => + _items.TryGetValue(innerPath ?? string.Empty, out ArchiveItem? item) ? item : null; + + /// Whether an inner path is a folder; the empty path - the archive's root - always is. + /// The / separated path inside the archive. + /// for a folder. + public bool IsDirectory(string innerPath) => + string.IsNullOrEmpty(innerPath) || Find(innerPath) is { IsDirectory: true }; + + /// The files and folders directly inside one folder of the archive. + /// The folder; empty for the archive's root. + /// The items, in catalogue order; empty for a path that is not a folder. + public IReadOnlyList List(string innerPath) => + _children.TryGetValue(innerPath ?? string.Empty, out List? list) ? list : []; + + /// What a folder of the archive holds, counted from the catalogue. + /// The file or folder; a file counts as itself. + /// The size of the files, and how many files and folders sit underneath. + public DirectorySize Measure(string innerPath) + { + ArchiveItem? item = Find(innerPath); + if (item is { IsDirectory: false }) + { + return new DirectorySize(item.Size, 1, 0, true); + } + + long bytes = 0; + long files = 0; + long folders = 0; + foreach (ArchiveItem child in _items.Values) + { + if (!IsUnder(child.Path, innerPath)) + { + continue; + } + + if (child.IsDirectory) + { + folders++; + } + else + { + files++; + bytes += child.Size; + } + } + + return new DirectorySize(bytes, files, folders, true); + } + + /// + /// Extracts files and folders into a folder on disk, each under its own name - a folder with + /// everything inside it. Never throws: every failure is recorded in the result. + /// + /// What to extract: paths inside the archive, all in one folder. + /// The folder to extract into; created when missing. + /// Filled in as the extraction runs, and polled for cancellation. + /// Called now and then while the extraction runs. + /// What happened. + public OperationResult Extract( + IReadOnlyList innerPaths, + string destination, + OperationProgress? progress = null, + Action? onProgress = null) + { + ArgumentNullException.ThrowIfNull(innerPaths); + ArgumentNullException.ThrowIfNull(destination); + + var result = new OperationResult("Extract"); + progress ??= new OperationProgress(); + var run = new Extraction(this, innerPaths, destination, progress, onProgress, result); + + try + { + run.Prepare(); + + switch (Kind) + { + case ArchiveKind.Zip: + ExtractZip(run); + break; + + case ArchiveKind.Tar: + case ArchiveKind.TarGzip: + ExtractTar(run); + break; + + default: + ExtractGzip(run); + break; + } + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or InvalidDataException or FormatException or ArgumentException or NotSupportedException) + { + result.AddError(FilePath, e); + } + + if (progress.Cancelled) + { + result.MarkCancelled(); + } + + run.Report(force: true); + return result; + } + + // ------------------------------------------------------------------ catalogue readers + + private void ReadZipCatalogue() + { + using ZipArchive zip = ZipFile.OpenRead(FilePath); + foreach (ZipArchiveEntry entry in zip.Entries) + { + string? path = Sanitize(entry.FullName); + if (path is null) + { + continue; + } + + bool directory = entry.FullName.EndsWith('/') || entry.FullName.EndsWith('\\'); + Add(path, directory, directory ? 0 : entry.Length, SafeLocal(entry.LastWriteTime), DosAttributes(entry.ExternalAttributes)); + } + } + + private void ReadTarCatalogue() + { + using Stream stream = OpenTarStream(); + using var reader = new TarReader(stream); + + while (reader.GetNextEntry(copyData: false) is TarEntry entry) + { + if (!IsTarContent(entry.EntryType, out bool directory)) + { + continue; + } + + string? path = Sanitize(entry.Name); + if (path is not null) + { + Add(path, directory, directory ? 0 : entry.Length, entry.ModificationTime.LocalDateTime, 0); + } + } + } + + private void ReadGzipCatalogue(FileInfo info) + { + // A lone gzip'd file holds exactly one member: the archive's own name without ".gz". Its + // size is the ISIZE trailer - the length modulo 2^32, exact for anything under 4 GB. + long size = 0; + using (FileStream stream = File.OpenRead(FilePath)) + { + if (stream.Length >= 18) + { + Span trailer = stackalloc byte[4]; + stream.Seek(-4, SeekOrigin.End); + stream.ReadExactly(trailer); + size = BitConverter.ToUInt32(trailer); + } + } + + Add(GzipMemberName(), directory: false, size, info.LastWriteTime, 0); + } + + private string GzipMemberName() + { + string name = System.IO.Path.GetFileNameWithoutExtension(FilePath); + return string.IsNullOrEmpty(name) ? "content" : name; + } + + private void Add(string path, bool directory, long size, DateTime modified, FileAttributes attributes) + { + EnsureParents(path, modified); + + var item = new ArchiveItem + { + Path = path, + IsDirectory = directory, + Size = size, + Modified = modified, + Attributes = directory ? FileAttributes.Directory | attributes : attributes, + }; + + string parent = ParentOf(path); + if (_items.TryGetValue(path, out ArchiveItem? existing)) + { + // A later entry for the same name wins, as tar extracts it - and an explicit folder + // entry replaces the one synthesised for its children, keeping its own time stamp. + List siblings = _children[parent]; + siblings[siblings.IndexOf(existing)] = item; + } + else + { + _children[parent].Add(item); + } + + _items[path] = item; + + if (directory && !_children.ContainsKey(path)) + { + _children[path] = []; + } + } + + private void EnsureParents(string path, DateTime modified) + { + string parent = ParentOf(path); + if (parent.Length == 0 || _items.ContainsKey(parent)) + { + return; + } + + EnsureParents(parent, modified); + + var folder = new ArchiveItem + { + Path = parent, + IsDirectory = true, + Modified = modified, + Attributes = FileAttributes.Directory, + }; + + _items[parent] = folder; + _children[ParentOf(parent)].Add(folder); + _children[parent] = []; + } + + // ------------------------------------------------------------------ extraction + + private void ExtractZip(Extraction run) + { + using ZipArchive zip = ZipFile.OpenRead(FilePath); + foreach (ZipArchiveEntry entry in zip.Entries) + { + if (run.Cancelled) + { + return; + } + + string? path = Sanitize(entry.FullName); + if (path is null || entry.FullName.EndsWith('/') || entry.FullName.EndsWith('\\')) + { + continue; + } + + run.ExtractFile(path, entry.Length, SafeLocal(entry.LastWriteTime), entry.Open); + } + } + + private void ExtractTar(Extraction run) + { + using Stream stream = OpenTarStream(); + using var reader = new TarReader(stream); + + while (!run.Cancelled && reader.GetNextEntry(copyData: false) is TarEntry entry) + { + if (!IsTarContent(entry.EntryType, out bool directory) || directory) + { + continue; + } + + string? path = Sanitize(entry.Name); + if (path is null) + { + continue; + } + + // The data stream belongs to the reader, which skips whatever is left of it on the next + // entry; disposing it here would make that skip throw. + run.ExtractFile(path, entry.Length, entry.ModificationTime.LocalDateTime, () => entry.DataStream ?? Stream.Null, ownsStream: false); + } + } + + private void ExtractGzip(Extraction run) + { + string name = GzipMemberName(); + ArchiveItem? item = Find(name); + run.ExtractFile( + name, + item?.Size ?? 0, + item?.Modified ?? FileStamp, + () => new GZipStream(File.OpenRead(FilePath), CompressionMode.Decompress)); + } + + private Stream OpenTarStream() + { + FileStream file = File.OpenRead(FilePath); + return Kind == ArchiveKind.TarGzip ? new GZipStream(file, CompressionMode.Decompress) : file; + } + + private static bool IsTarContent(TarEntryType type, out bool directory) + { + directory = type == TarEntryType.Directory; + return directory || type is TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.ContiguousFile; + } + + /// One extraction run: which items were asked for, where they go, and the counters. + private sealed class Extraction + { + private readonly ArchiveFile _archive; + private readonly IReadOnlyList _roots; + private readonly string _destination; + private readonly OperationProgress _progress; + private readonly Action? _onProgress; + private readonly OperationResult _result; + private long _lastReport; + + public Extraction( + ArchiveFile archive, + IReadOnlyList roots, + string destination, + OperationProgress progress, + Action? onProgress, + OperationResult result) + { + _archive = archive; + _roots = [.. roots.Where(static r => !string.IsNullOrEmpty(r))]; + _destination = System.IO.Path.GetFullPath(destination); + _progress = progress; + _onProgress = onProgress; + _result = result; + } + + public bool Cancelled => _progress.Cancelled; + + /// Counts the totals from the catalogue and creates every folder asked for, empty ones included. + public void Prepare() + { + Directory.CreateDirectory(_destination); + + long files = 0; + long bytes = 0; + foreach (ArchiveItem item in _archive.Items) + { + string? target = TargetFor(item.Path); + if (target is null) + { + continue; + } + + if (item.IsDirectory) + { + Directory.CreateDirectory(target); + } + else + { + files++; + bytes += item.Size; + } + } + + _progress.TotalFiles = files; + _progress.TotalBytes = bytes; + _progress.TotalsKnown = true; + Report(force: true); + } + + public void ExtractFile(string path, long size, DateTime modified, Func open, bool ownsStream = true) + { + string? target = TargetFor(path); + if (target is null) + { + return; + } + + _progress.BeginFile(ArchivePath.Combine(_archive.FilePath, path), target, size); + Report(force: true); + + try + { + string? folder = System.IO.Path.GetDirectoryName(target); + if (!string.IsNullOrEmpty(folder)) + { + Directory.CreateDirectory(folder); + } + + Stream source = open(); + try + { + using var output = new FileStream(target, FileMode.Create, FileAccess.Write, FileShare.None); + byte[] block = new byte[CopyBlock]; + int read; + while ((read = source.Read(block, 0, block.Length)) > 0) + { + output.Write(block, 0, read); + _progress.Advance(read); + Report(force: false); + + if (Cancelled) + { + break; + } + } + } + finally + { + if (ownsStream) + { + source.Dispose(); + } + } + + if (Cancelled) + { + TryDelete(target); + return; + } + + TrySetTime(target, modified); + _progress.CompleteFile(); + _result.FilesProcessed++; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or InvalidDataException or FormatException or ArgumentException or NotSupportedException) + { + _progress.CompleteFile(); + _result.AddError(ArchivePath.Combine(_archive.FilePath, path), e); + } + } + + public void Report(bool force) + { + long now = Environment.TickCount64; + if (!force && now - _lastReport < ReportIntervalMs) + { + return; + } + + _lastReport = now; + _onProgress?.Invoke(); + } + + /// + /// Where an item lands: under the destination by its path relative to the folder the roots + /// sit in, or when it is not one of the roots nor inside one. + /// + private string? TargetFor(string path) + { + foreach (string root in _roots) + { + if (!IsUnder(path, root) && !string.Equals(path, root, StringComparison.Ordinal)) + { + continue; + } + + string parent = ParentOf(root); + string relative = parent.Length == 0 ? path : path[(parent.Length + 1)..]; + string target = System.IO.Path.GetFullPath( + System.IO.Path.Combine(_destination, relative.Replace('/', System.IO.Path.DirectorySeparatorChar))); + + // The names were sanitised when read; this is the belt to that pair of braces. + string prefix = _destination.EndsWith(System.IO.Path.DirectorySeparatorChar) + ? _destination + : _destination + System.IO.Path.DirectorySeparatorChar; + return target.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? target : null; + } + + return null; + } + + private static void TrySetTime(string path, DateTime modified) + { + if (modified == default) + { + return; + } + + try + { + File.SetLastWriteTime(path, modified); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException) + { + // A time stamp is not worth failing an extraction over. + } + } + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + // A half-written file left behind by a cancel is untidy, not harmful. + } + } + } + + // ------------------------------------------------------------------ names + + /// + /// Normalises an entry name to the catalogue's form, or for one that must + /// never be extracted: empty, rooted, or climbing out with ... + /// + /// The name as the archive stores it. + /// The / separated path, or . + public static string? Sanitize(string? name) + { + if (string.IsNullOrEmpty(name)) + { + return null; + } + + string[] parts = name.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + var kept = new List(parts.Length); + foreach (string part in parts) + { + if (part == ".") + { + continue; + } + + if (part == ".." || part.Contains(':', StringComparison.Ordinal)) + { + return null; + } + + kept.Add(part); + } + + if (kept.Count == 0 || name[0] is '/' or '\\') + { + return null; + } + + return string.Join('/', kept); + } + + /// The folder an inner path sits in; empty at the archive's root. + /// The / separated path. + /// The parent path. + public static string ParentOf(string path) + { + int slash = path.LastIndexOf('/'); + return slash < 0 ? string.Empty : path[..slash]; + } + + /// The last segment of an inner path. + /// The / separated path. + /// The name. + public static string LeafOf(string path) + { + int slash = path.LastIndexOf('/'); + return slash < 0 ? path : path[(slash + 1)..]; + } + + /// Joins a folder and a name inside the archive. + /// The folder; empty for the root. + /// The name. + /// The joined path. + public static string Join(string folder, string name) => + string.IsNullOrEmpty(folder) ? name : folder + "/" + name; + + private static bool IsUnder(string path, string folder) => + folder.Length == 0 + ? path.Length > 0 + : path.Length > folder.Length + 1 && + path.StartsWith(folder, StringComparison.Ordinal) && + path[folder.Length] == '/'; + + private static FileAttributes DosAttributes(int external) + { + // The low byte holds DOS attributes when the archive was made on Windows; a Unix-made one + // keeps its mode in the high word and leaves the low byte zero. + const FileAttributes kept = FileAttributes.ReadOnly | FileAttributes.Hidden | FileAttributes.System | FileAttributes.Archive; + return (FileAttributes)(external & 0xFF) & kept; + } + + private static DateTime SafeLocal(DateTimeOffset stamp) + { + try + { + return stamp.LocalDateTime; + } + catch (ArgumentOutOfRangeException) + { + return default; + } + } +} diff --git a/src/OpenStus/Archives/ArchivePath.cs b/src/OpenStus/Archives/ArchivePath.cs new file mode 100644 index 0000000..8bebde7 --- /dev/null +++ b/src/OpenStus/Archives/ArchivePath.cs @@ -0,0 +1,127 @@ +using OpenStus.Files; + +namespace OpenStus.Archives; + +/// +/// The paths a panel shows while it is inside an archive: the archive file's own path with the +/// path inside the archive appended, C:\Downloads\tools.zip\bin\run.cmd. +/// +/// +/// Such a path reads naturally in the panel title and on the command line, walks up with the +/// ordinary parent rules - the parent of the archive's root is the folder holding the archive - +/// and survives the folder history and the saved tabs, because can always +/// take it apart again. +/// +public static class ArchivePath +{ + private static StringComparison PathComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + /// Builds the panel path for a place inside an archive. + /// The archive file. + /// The / separated path inside it; empty for its root. + /// The combined path, with platform separators. + public static string Combine(string archiveFile, string? innerPath) => + string.IsNullOrEmpty(innerPath) + ? archiveFile + : archiveFile + Path.DirectorySeparatorChar + innerPath.Replace('/', Path.DirectorySeparatorChar); + + /// + /// Takes the path inside a known archive back out of a panel path. + /// + /// The panel path. + /// The archive file. + /// The / separated path inside the archive; empty for its root. + /// when is the archive or lies inside it. + public static bool TryGetInner(string path, string archiveFile, out string innerPath) + { + innerPath = string.Empty; + if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(archiveFile)) + { + return false; + } + + if (string.Equals(path, archiveFile, PathComparison)) + { + return true; + } + + if (path.Length <= archiveFile.Length + 1 || + !path.StartsWith(archiveFile, PathComparison) || + !IsSeparator(path[archiveFile.Length])) + { + return false; + } + + innerPath = path[(archiveFile.Length + 1)..] + .Replace('\\', '/') + .Trim('/'); + return true; + } + + /// + /// Splits a path that runs through an archive into the archive file and the path inside it. + /// Touches the disk: the path is walked up until something on it exists. + /// + /// The path to split. + /// The archive file. + /// The / separated path inside it; empty for its root. + /// + /// when the nearest existing thing on the path is a supported archive + /// file; for an ordinary folder, a missing path, or any other file. + /// + public static bool TrySplit(string? path, out string archiveFile, out string innerPath) + { + archiveFile = string.Empty; + innerPath = string.Empty; + + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + string? candidate = FileSystemProvider.NormalizeDisplayPath(path); + var tail = new List(); + + try + { + while (!string.IsNullOrEmpty(candidate)) + { + if (Directory.Exists(candidate)) + { + return false; + } + + if (File.Exists(candidate)) + { + if (!ArchiveFile.CanOpen(candidate)) + { + return false; + } + + tail.Reverse(); + archiveFile = candidate; + innerPath = string.Join('/', tail); + return true; + } + + tail.Add(Path.GetFileName(candidate)); + candidate = Path.GetDirectoryName(candidate); + } + } + catch (Exception e) when (e is ArgumentException or NotSupportedException or PathTooLongException or IOException or UnauthorizedAccessException) + { + return false; + } + + return false; + } + + /// Whether a panel could show a path: a folder that exists, or a place inside an archive. + /// The path. + /// when the path can be navigated to. + public static bool CanNavigate(string? path) => + FileSystemProvider.DirectoryExists(path) || TrySplit(path, out _, out _); + + private static bool IsSeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; +} diff --git a/src/OpenStus/Archives/TempArea.cs b/src/OpenStus/Archives/TempArea.cs new file mode 100644 index 0000000..b6a2794 --- /dev/null +++ b/src/OpenStus/Archives/TempArea.cs @@ -0,0 +1,79 @@ +namespace OpenStus.Archives; + +/// +/// Scratch folders for files taken out of an archive to be viewed, run or copied. Everything lives +/// under one folder per process in the system temp directory, removed when the shell exits. +/// +public static class TempArea +{ + private static readonly object Sync = new(); + private static string? _root; + + /// The per-process scratch root; created on first use. + public static string Root + { + get + { + lock (Sync) + { + _root ??= Path.Combine( + Path.GetTempPath(), + "OpenStus", + Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + "-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(_root); + return _root; + } + } + } + + /// Creates a fresh, empty folder under . + /// The folder's full path. + public static string CreateDirectory() + { + string path = Path.Combine(Root, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + /// Deletes a folder and everything in it. Never throws. + /// The folder. + public static void Delete(string? path) + { + if (string.IsNullOrEmpty(path)) + { + return; + } + + try + { + if (Directory.Exists(path)) + { + // Extracted files may carry the read-only bit the archive recorded. + foreach (string file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(path, recursive: true); + } + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException) + { + // A file still held open by a program the user started from an archive stays behind; + // the system's temp cleanup gets it later. + } + } + + /// Removes the whole per-process scratch root. Never throws. + public static void Cleanup() + { + string? root; + lock (Sync) + { + root = _root; + _root = null; + } + + Delete(root); + } +} diff --git a/src/OpenStus/Core/Application.cs b/src/OpenStus/Core/Application.cs index 750bcdb..4f60fc9 100644 --- a/src/OpenStus/Core/Application.cs +++ b/src/OpenStus/Core/Application.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text; +using OpenStus.Archives; using OpenStus.Editor; using OpenStus.Files; using OpenStus.Input; @@ -510,7 +511,7 @@ private void PlaceCursor() return; case FileEditor editor: - Terminal.SetCursor(editor.CursorScreenX, editor.CursorScreenY, true); + Terminal.SetCursor(editor.CursorScreenX, editor.CursorScreenY, editor.CursorVisible, editor.CursorShape); return; default: @@ -837,7 +838,8 @@ private void HandleGlobalKey(KeyEvent key) if (CommandLineRow >= 0) { - if (_commandLine.HandleKey(key, this)) + // No panel is there to claim Shift and the arrows, so they select text. + if (_commandLine.HandleKey(key, this, selectWithShift: true)) { return; } @@ -1021,7 +1023,7 @@ public void RunShellCommand(string command) int code = CommandExecutor.Run( command, - ActiveFilePanel.CurrentPath, + ActiveFilePanel.WorkingDirectory, Terminal, out string? changeDirectory, resumeAltScreen: !stayOnUserScreen, @@ -1123,19 +1125,43 @@ public void ViewCurrent() return; } - // TryOpen has already shown its own detailed error box when it answers null, so a second - // generic dialog here would be both redundant and wrong. - FileViewer? viewer = FileViewer.TryOpen(Theme, Ui, entry.FullPath); - if (viewer is null) + // Inside an archive the file is taken out to a scratch folder for the viewer to read, and + // the folder goes as soon as the viewer closes. + string? scratch = null; + string path = entry.FullPath; + if (ActiveFilePanel.InArchive) { - return; + string? extracted = ActiveFilePanel.ExtractToTemp(entry, out string? error); + if (extracted is null) + { + _ui.Error("View", error ?? "The file could not be extracted."); + return; + } + + path = extracted; + scratch = Path.GetDirectoryName(extracted); } - viewer.SyntaxHighlight = Settings.SyntaxHighlight; + try + { + // TryOpen has already shown its own detailed error box when it answers null, so a + // second generic dialog here would be both redundant and wrong. + FileViewer? viewer = FileViewer.TryOpen(Theme, Ui, path); + if (viewer is null) + { + return; + } + + viewer.SyntaxHighlight = Settings.SyntaxHighlight; - using (viewer) + using (viewer) + { + RunModal(viewer); + } + } + finally { - RunModal(viewer); + TempArea.Delete(scratch); } } @@ -1154,6 +1180,11 @@ public void EditCurrent() return; } + if (RefuseInArchive(ActiveFilePanel, "Edit")) + { + return; + } + // As with F3: a null from TryOpen means the editor already told the user why, or the user // themselves said no to opening a binary file. FileEditor? editor = FileEditor.TryOpen(Theme, Ui, entry.FullPath, _editorClipboard); @@ -1174,6 +1205,11 @@ public void EditCurrent() public void EditNewFile() { FilePanel panel = ActiveFilePanel; + if (RefuseInArchive(panel, "Edit")) + { + return; + } + string? name = _ui.Input("Edit", "Create and edit the file:", string.Empty, "newfile"); if (string.IsNullOrWhiteSpace(name)) @@ -1211,6 +1247,12 @@ private void Transfer(bool move, bool currentOnly) IReadOnlyList sources = SourcesFor(panel, currentOnly); string title = move ? "Rename or move" : "Copy"; + // Moving out of an archive would mean deleting from it, and archives are read-only here. + if (move && RefuseInArchive(panel, title)) + { + return; + } + if (sources.Count == 0) { _ui.Message(title, ["There is nothing to " + (move ? "move" : "copy") + "."], MessageButtons.Ok); @@ -1232,8 +1274,23 @@ private void Transfer(bool move, bool currentOnly) } string destination = ResolvePath(panel.CurrentPath, answer); + if (DestinationInArchive(destination)) + { + _ui.Message( + title, + ["Archives are read-only in Open Stus:", "nothing can be copied or moved into one."], + MessageButtons.Ok); + return; + } + OperationOptions options = OperationOptionsFor(permanent: false); + if (panel.InArchive) + { + CopyOutOfArchive(panel, sources, destination, title, options); + return; + } + OperationResult result = RunWithProgress( title, (progress, report, overwrite, error) => move @@ -1243,10 +1300,124 @@ private void Transfer(bool move, bool currentOnly) AfterOperation(title, result); } + /// + /// F5 inside an archive: the chosen entries are extracted to a scratch folder and copied from + /// there with the ordinary copy - so the destination rules, the overwrite questions and the + /// progress are exactly those of a copy between two folders. + /// + private void CopyOutOfArchive( + FilePanel panel, + IReadOnlyList sources, + string destination, + string title, + OperationOptions options) + { + ArchiveFile? archive = panel.Archive; + if (archive is null) + { + return; // an unreadable archive lists nothing to copy + } + + List innerPaths = [.. sources.Select(panel.ArchiveInnerPathOf)]; + + string staging; + try + { + staging = TempArea.CreateDirectory(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + _ui.Error(title, "Cannot create a scratch folder: " + e.Message); + return; + } + + try + { + OperationResult result = RunWithProgress( + title, + (progress, report, overwrite, error) => + { + OperationResult extracted = archive.Extract(innerPaths, staging, progress, report); + if (extracted.Cancelled) + { + return extracted; + } + + List staged = + [.. FileSystemProvider.Read(staging, includeHidden: true).Entries.Where(static e => !e.IsParent)]; + + progress.Reset(); + progress.Title = title; + OperationResult copied = FileOperations.Copy(staged, destination, options, progress, report, overwrite, error); + + foreach (OperationError failure in extracted.Errors) + { + copied.AddError(failure.Operation, failure.Path, failure.Message, failure.Exception); + } + + return copied; + }); + + AfterOperation(title, result); + } + finally + { + TempArea.Delete(staging); + } + } + + /// + /// Whether a copy or move destination lies inside an archive: a path running on past an + /// archive file, or the archive itself when a panel is showing it as a folder. A plain archive + /// file named as the destination of a single file is still an ordinary overwrite. + /// + private bool DestinationInArchive(string destination) + { + if (FileSystemProvider.DirectoryExists(destination) || + !ArchivePath.TrySplit(destination, out string archiveFile, out string innerPath)) + { + return false; + } + + if (innerPath.Length > 0 || + destination.EndsWith(Path.DirectorySeparatorChar) || + destination.EndsWith(Path.AltDirectorySeparatorChar)) + { + return true; + } + + return (_left.InArchive && string.Equals(_left.CurrentPath, archiveFile, PathComparison)) || + (_right.InArchive && string.Equals(_right.CurrentPath, archiveFile, PathComparison)); + } + + /// + /// Refuses a command that would change the archive a panel is browsing - archives are + /// read-only here - and says how to get at the files instead. + /// + /// when the command was refused. + private bool RefuseInArchive(FilePanel panel, string title) + { + if (!panel.InArchive) + { + return false; + } + + _ui.Message( + title, + ["Archives are read-only in Open Stus.", "Copy the files out with F5 to change them."], + MessageButtons.Ok); + return true; + } + /// Creates a folder, nested paths included (F7). public void MakeDirectory() { FilePanel panel = ActiveFilePanel; + if (RefuseInArchive(panel, "Create folder")) + { + return; + } + string? name = _ui.Input("Create folder", "Create the folder:", string.Empty, "mkdir"); if (string.IsNullOrWhiteSpace(name)) @@ -1278,6 +1449,11 @@ public void DeleteFiles(bool permanent, bool currentOnly = false) FilePanel panel = ActiveFilePanel; IReadOnlyList sources = SourcesFor(panel, currentOnly); + if (RefuseInArchive(panel, "Delete")) + { + return; + } + if (sources.Count == 0) { _ui.Message("Delete", ["There is nothing to delete."], MessageButtons.Ok); @@ -1436,14 +1612,55 @@ public void ShowDriveMenu(bool left) panel.IsVisible = true; Layout(); - panel.Navigate(drives[index].Root); + panel.Navigate(DriveTarget(drives, index, (left ? _right : _left).CurrentPath)); _dirty = true; } + /// + /// Where picking a drive in the drive menu leads: the other panel's folder when the other panel + /// is on that very drive - the quickest way to line both panels up - and the drive's root + /// otherwise. + /// + /// The drives the menu offered. + /// The index of the drive picked. + /// The other panel's folder. + /// The folder to show. + public static string DriveTarget(IReadOnlyList drives, int picked, string? otherPath) + { + ArgumentNullException.ThrowIfNull(drives); + + string root = drives[picked].Root; + if (string.IsNullOrEmpty(otherPath)) + { + return root; + } + + // The longest matching root owns the path: on Unix "/" matches everything, and a mount + // point such as "/mnt/data" is the drive that actually holds a path underneath it. + int owner = -1; + for (int i = 0; i < drives.Count; i++) + { + string candidate = drives[i].Root; + if (candidate.Length > 0 && + otherPath.StartsWith(candidate, PathComparison) && + (owner < 0 || candidate.Length > drives[owner].Root.Length)) + { + owner = i; + } + } + + return owner == picked ? otherPath : root; + } + /// Runs a recursive file search and jumps to what the user picks (Alt+F7). public void FindFiles() { FilePanel panel = ActiveFilePanel; + if (panel.InArchive) + { + _ui.Message("Find file", ["Find file searches folders on disk.", "Leave the archive to search from here."], MessageButtons.Ok); + return; + } string? mask = _ui.Input("Find file", "A file mask or several file masks:", "*", "findmask"); if (mask is null) @@ -1594,6 +1811,19 @@ public void ShowDirectorySize() return; } + // Inside an archive the catalogue already knows every size; nothing needs walking. + if (panel.Archive is ArchiveFile archive) + { + DirectorySize sum = DirectorySize.Empty; + foreach (FileEntry target in targets) + { + sum += archive.Measure(panel.ArchiveInnerPathOf(target)); + } + + ShowSizeMessage(targets, sum); + return; + } + var dialog = new ProgressDialog(Theme, "Folder size"); dialog.Layout(ModalArea); @@ -1636,8 +1866,10 @@ public void ShowDirectorySize() _dirty = true; } - DirectorySize total = DirectorySizeCalculator.Total(results); + ShowSizeMessage(targets, DirectorySizeCalculator.Total(results)); + } + private void ShowSizeMessage(List targets, DirectorySize total) => _ui.Message( "Folder size", [ @@ -1649,7 +1881,6 @@ public void ShowDirectorySize() (total.Complete ? string.Empty : " (incomplete)"), ], MessageButtons.Ok); - } /// Tags the entries that are missing from, or newer than, the other panel's. public void CompareDirectories() @@ -2013,7 +2244,7 @@ public void SyncWorkingDirectory() return; } - string path = ActiveFilePanel.CurrentPath; + string path = ActiveFilePanel.WorkingDirectory; if (string.IsNullOrEmpty(path) || string.Equals(path, _workingDirectory, PathComparison)) { return; @@ -2288,7 +2519,9 @@ private static string ResolvePath(string baseDirectory, string input) { string expanded = Environment.ExpandEnvironmentVariables(path.Trim().Trim('"')); string full = Path.GetFullPath(expanded); - return Directory.Exists(full) ? full : null; + + // An archive, or a folder inside one, is a place a panel can start in too. + return ArchivePath.CanNavigate(full) ? full : null; } catch (Exception e) when (e is ArgumentException or NotSupportedException or PathTooLongException or IOException or UnauthorizedAccessException) { diff --git a/src/OpenStus/Editor/FileEditor.cs b/src/OpenStus/Editor/FileEditor.cs index 55eb844..83ee1cd 100644 --- a/src/OpenStus/Editor/FileEditor.cs +++ b/src/OpenStus/Editor/FileEditor.cs @@ -142,7 +142,8 @@ public FileEditor(Theme theme, IUiServices ui, TextBuffer buffer, IEditorClipboa /// /// Colour the text by the file type's syntax (C#, JSON, SQL, ...). On by default; a file whose - /// extension no rules cover is simply drawn plain. + /// extension no language claims still gets its numbers, strings and comment lines coloured. + /// F5 switches it for the file being edited. /// public bool SyntaxHighlight { get; set; } = true; @@ -152,6 +153,15 @@ public FileEditor(Theme theme, IUiServices ui, TextBuffer buffer, IEditorClipboa /// The screen row the hardware caret should be placed at. public int CursorScreenY { get; private set; } + /// Whether the caret is inside the text area, so the hardware cursor should be shown. + public bool CursorVisible { get; private set; } = true; + + /// + /// The shape the hardware caret takes: the console's blinking bar while typing inserts, a + /// blinking block while it overwrites - so the Insert key's state can be seen at the caret. + /// + public CursorShape CursorShape => Overwrite ? CursorShape.BlinkingBlock : CursorShape.BlinkingBar; + /// The index of the top visible line. public int TopLine => _topLine; @@ -167,7 +177,7 @@ public FileEditor(Theme theme, IUiServices ui, TextBuffer buffer, IEditorClipboa KeyMods.Shift => BaseKeyBar.WithLabel(1, "SaveAs").WithLabel(6, "Next"), KeyMods.Ctrl => BaseKeyBar.WithLabel(6, "Replac"), KeyMods.Alt => BaseKeyBar.WithLabel(7, "GoTo"), - _ => BaseKeyBar, + _ => BaseKeyBar.WithLabel(4, SyntaxHighlight ? "Plain" : "Colour"), }; /// @@ -192,7 +202,7 @@ public void Draw(ScreenBuffer buffer) } DrawScrollBar(buffer, rows); - DrawCaret(buffer, rows, width); + PlaceCaret(rows, width); if (_area.Height >= 2) { @@ -353,6 +363,10 @@ private bool HandleKey(KeyEvent key) SaveAs(); return true; + case ConsoleKey.F5 when key.Mods == KeyMods.None: + SyntaxHighlight = !SyntaxHighlight; + return true; + case ConsoleKey.F7 when key.Mods == KeyMods.None: Search(fresh: true); return true; @@ -980,7 +994,7 @@ int Advance(int toChar) if (!string.Equals(_syntaxPath, FilePath, StringComparison.Ordinal)) { _syntaxPath = FilePath; - _syntaxRules = SyntaxRegistry.ForPath(FilePath); + _syntaxRules = SyntaxRegistry.ForPathOrPlainText(FilePath); _lineStates = new LineStateCache(); } @@ -1019,25 +1033,20 @@ private void DrawScrollBar(ScreenBuffer buffer, int rows) } } - private void DrawCaret(ScreenBuffer buffer, int rows, int width) + /// + /// Works out where the hardware caret goes. Nothing is painted: the caret is the terminal's own + /// blinking cursor, which the shell places and shapes from , + /// and . + /// + private void PlaceCaret(int rows, int width) { int display = TextBuffer.ToDisplayColumn(_buffer.GetLine(_cursor.Line), _cursor.Column, _buffer.TabSize); - int x = _area.X + display - _leftColumn; - int y = _area.Y + _cursor.Line - _topLine; - - CursorScreenX = x; - CursorScreenY = y; - - if (display - _leftColumn < 0 || display - _leftColumn >= width || - _cursor.Line - _topLine < 0 || _cursor.Line - _topLine >= rows) - { - return; - } - // The hardware caret is the host's job; inverting the cell keeps the caret visible in a - // screenshot and on a terminal that hides it. - var under = buffer.Get(x, y); - buffer.Set(x, y, under.Glyph, new CellStyle(under.Style.Bg, under.Style.Fg)); + CursorScreenX = _area.X + display - _leftColumn; + CursorScreenY = _area.Y + _cursor.Line - _topLine; + CursorVisible = + display - _leftColumn >= 0 && display - _leftColumn < width && + _cursor.Line - _topLine >= 0 && _cursor.Line - _topLine < rows; } private void DrawStatus(ScreenBuffer buffer, int row) diff --git a/src/OpenStus/Files/FileEntry.cs b/src/OpenStus/Files/FileEntry.cs index 3778c81..b32d0b0 100644 --- a/src/OpenStus/Files/FileEntry.cs +++ b/src/OpenStus/Files/FileEntry.cs @@ -104,6 +104,12 @@ public bool IsExecutable /// Whether the extension is one of the archive formats the panel colours differently. public bool IsArchive => !IsDirectory && FileTypeClassifier.IsArchiveExtension(Extension); + /// Whether the name marks a backup or temporary file (*.bak, *.tmp, *~...). + public bool IsTemporary => !IsDirectory && FileTypeClassifier.IsTemporaryName(Name); + + /// Whether the extension is an image, sound or video format. + public bool IsMedia => !IsDirectory && FileTypeClassifier.IsMediaExtension(Extension); + /// /// The extension in lower case and without the dot, or an empty string when the name /// has none. A leading dot is part of the name (".gitignore" has no extension). diff --git a/src/OpenStus/Files/FileTypeClassifier.cs b/src/OpenStus/Files/FileTypeClassifier.cs index 47f92c4..970f495 100644 --- a/src/OpenStus/Files/FileTypeClassifier.cs +++ b/src/OpenStus/Files/FileTypeClassifier.cs @@ -6,7 +6,8 @@ namespace OpenStus.Files; /// /// The panel adds the two states the file system knows nothing about - the cursor bar and the tag /// mark - on top of this, giving the full order -/// cursor > tagged > directory > hidden > archive > executable > normal. +/// cursor > tagged > directory > hidden > archive > executable > temporary > +/// media > normal. /// public enum FileCategory { @@ -24,6 +25,12 @@ public enum FileCategory /// A file that runs. Executable, + + /// A backup or temporary file: *.bak, *.tmp, *~ and the like. + Temporary, + + /// An image, sound or video file. + Media, } /// @@ -41,7 +48,26 @@ public static class FileTypeClassifier ["bat", "cmd", "com", "exe", "msi", "ps1"]; private static readonly string[] ArchiveList = - ["7z", "bz2", "cab", "gz", "iso", "rar", "tar", "xz", "zip"]; + ["7z", "apk", "arj", "bz2", "cab", "deb", "ear", "gz", "iso", "jar", "lz", "lzh", "lzma", + "nupkg", "rar", "rpm", "tar", "tbz2", "tgz", "txz", "vsix", "war", "whl", "wim", "xz", "z", + "zip", "zst"]; + + private static readonly string[] TemporaryList = + ["$$$", "backup", "bak", "bk", "bkp", "crdownload", "dmp", "mdmp", "old", "orig", "part", + "partial", "rej", "swo", "swp", "temp", "tmp"]; + + private static readonly string[] MediaList = + [ + // Images. + "avif", "bmp", "gif", "heic", "ico", "jpeg", "jpg", "png", "psd", "svg", "tga", "tif", + "tiff", "webp", + + // Sound. + "aac", "aiff", "flac", "m4a", "mid", "midi", "mp3", "ogg", "opus", "wav", "wma", + + // Video. + "3gp", "avi", "flv", "m4v", "mkv", "mov", "mp4", "mpeg", "mpg", "webm", "wmv", + ]; private static readonly HashSet ExecutableSet = new(ExecutableList, StringComparer.OrdinalIgnoreCase); @@ -49,12 +75,45 @@ public static class FileTypeClassifier private static readonly HashSet ArchiveSet = new(ArchiveList, StringComparer.OrdinalIgnoreCase); + private static readonly HashSet TemporarySet = + new(TemporaryList, StringComparer.OrdinalIgnoreCase); + + private static readonly HashSet MediaSet = + new(MediaList, StringComparer.OrdinalIgnoreCase); + /// The extensions (lower case, no dot) treated as executables on Windows. public static IReadOnlyList ExecutableExtensions => ExecutableList; /// The extensions (lower case, no dot) treated as archives on every platform. public static IReadOnlyList ArchiveExtensions => ArchiveList; + /// The extensions (lower case, no dot) treated as backup or temporary files. + public static IReadOnlyList TemporaryExtensions => TemporaryList; + + /// The extensions (lower case, no dot) treated as images, sound and video. + public static IReadOnlyList MediaExtensions => MediaList; + + /// + /// Tests whether a file name is a backup or temporary file: a temporary extension, an editor + /// backup ending in ~, or an Office owner file starting with ~$. + /// + /// The file name. + /// for a backup or temporary file. + public static bool IsTemporaryName(string? name) => + !string.IsNullOrEmpty(name) && + (name.EndsWith('~') || + name.StartsWith("~$", StringComparison.Ordinal) || + TemporarySet.Contains(ExtensionOf(name))); + + /// + /// Tests whether - written without a dot, in any case - is an + /// image, sound or video extension. + /// + /// The extension to test. + /// for a media extension. + public static bool IsMediaExtension(string? extension) => + !string.IsNullOrEmpty(extension) && MediaSet.Contains(extension); + /// /// Tests whether - written without a dot, in any case - is one of /// the Windows executable extensions. @@ -180,6 +239,16 @@ public static FileCategory Classify(FileEntry entry) return FileCategory.Archive; } - return entry.IsExecutable ? FileCategory.Executable : FileCategory.Normal; + if (entry.IsExecutable) + { + return FileCategory.Executable; + } + + if (entry.IsTemporary) + { + return FileCategory.Temporary; + } + + return entry.IsMedia ? FileCategory.Media : FileCategory.Normal; } } diff --git a/src/OpenStus/Panels/FilePanel.cs b/src/OpenStus/Panels/FilePanel.cs index a0faa5b..7430232 100644 --- a/src/OpenStus/Panels/FilePanel.cs +++ b/src/OpenStus/Panels/FilePanel.cs @@ -1,7 +1,9 @@ using System.Globalization; +using OpenStus.Archives; using OpenStus.Core; using OpenStus.Files; using OpenStus.Input; +using OpenStus.Operations; using OpenStus.Rendering; using OpenStus.Theming; using OpenStus.Ui; @@ -70,6 +72,13 @@ public sealed class FilePanel : IFilePanel private long _totalBytes; private bool? _shiftSelection; + // Inside an archive: the archive file (set even when it failed to open, so the panel still + // knows where it is), its catalogue, the folder inside it, and why it could not be read. + private string? _archiveFile; + private ArchiveFile? _archive; + private string _archiveInner = string.Empty; + private string? _archiveError; + /// Creates a panel. /// /// The application context, or when the panel is built before the shell @@ -113,6 +122,32 @@ public FilePanel(IAppContext? ctx, Theme theme, bool isLeft) /// public string CurrentPath => _path; + /// Whether the panel is showing the inside of an archive rather than a folder. + public bool InArchive => _archiveFile is not null; + + /// The archive being browsed, or outside one or when it could not be read. + public ArchiveFile? Archive => _archive; + + /// The / separated folder inside the archive; empty at its root or outside one. + public string ArchiveInnerPath => _archiveInner; + + /// + /// The real folder behind the panel: the folder shown, or - inside an archive - the one holding + /// the archive file. Commands run here, since a path inside an archive is not a place a + /// process can start in. + /// + public string WorkingDirectory => + _archiveFile is null ? _path : Path.GetDirectoryName(_archiveFile) ?? _path; + + /// The path inside the archive of an entry of this panel. + /// An entry of the current listing. + /// The / separated path. + public string ArchiveInnerPathOf(FileEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + return ArchiveFile.Join(_archiveInner, entry.Name); + } + /// public FileEntry? Current => (uint)_cursor < (uint)_entries.Count ? _entries[_cursor] : null; @@ -276,6 +311,7 @@ public void Navigate(string path, string? focusName = null) _quickSearch.Cancel(); _shiftSelection = null; _top = 0; + ResolveArchive(_path); Load(focusName); _history.Push(_path); } @@ -287,6 +323,14 @@ public void Reload(bool keepPosition = true) int top = _top; _quickSearch.Cancel(); _shiftSelection = null; + + // A re-read inside an archive only reopens it when the file on disk has changed - a + // tarball is decompressed end to end to read its catalogue. + if (_archiveFile is not null && (_archive is null || !_archive.IsCurrent())) + { + OpenArchive(_archiveFile, _archiveInner); + } + Load(focus); if (keepPosition) @@ -452,7 +496,7 @@ public int RestoreTabs(IReadOnlyList paths, int active) for (int i = 0; i < paths.Count; i++) { string path = paths[i]; - if (string.IsNullOrWhiteSpace(path) || !FileSystemProvider.DirectoryExists(path)) + if (string.IsNullOrWhiteSpace(path) || !ArchivePath.CanNavigate(path)) { continue; } @@ -1115,7 +1159,8 @@ private static string SizeCellText(FileEntry entry) /// /// Picks the colour for one entry, in the fixed precedence - /// cursor > tagged > hidden or system > directory > archive > executable > plain file. + /// cursor > tagged > hidden or system > directory > archive > executable > + /// backup or temporary > media > plain file. /// /// /// Hidden and system deliberately outrank directory. A drive root's $Recycle.Bin, @@ -1156,7 +1201,17 @@ public CellStyle StyleFor(FileEntry entry, bool onCursor) return Theme.PanelArchive; } - return entry.IsExecutable ? Theme.PanelExecutable : Theme.PanelText; + if (entry.IsExecutable) + { + return Theme.PanelExecutable; + } + + if (entry.IsTemporary) + { + return Theme.PanelTemporary; + } + + return entry.IsMedia ? Theme.PanelMedia : Theme.PanelText; } // ------------------------------------------------------------ key handling @@ -1476,6 +1531,10 @@ internal void SetEntriesForTest(string path, IReadOnlyList entries, s _cursor = 0; _top = 0; _shiftSelection = null; + _archiveFile = null; + _archive = null; + _archiveInner = string.Empty; + _archiveError = null; Recount(); } @@ -1631,17 +1690,89 @@ private void Activate(IAppContext? app) return; } + if (_archiveFile is not null) + { + // A file inside an archive has no path a program could open, so it is taken out to + // the scratch area first and run from there. + string? extracted = ExtractToTemp(current, out string? error); + if (extracted is null) + { + app?.Ui.Error("Open", error ?? "The file could not be extracted."); + return; + } + + app?.RunShellCommand("\"" + extracted + "\""); + return; + } + + if (ArchiveFile.CanOpen(current.Name)) + { + Navigate(current.FullPath); + return; + } + app?.RunShellCommand("\"" + current.FullPath + "\""); } + /// + /// Extracts one entry of the archive being browsed into a fresh scratch folder, for viewing or + /// running. The caller owns the folder and deletes it with when done. + /// + /// A file of the current listing. + /// Why it failed, or . + /// The extracted file's full path, or on failure. + public string? ExtractToTemp(FileEntry entry, out string? error) + { + ArgumentNullException.ThrowIfNull(entry); + + error = null; + if (_archive is null) + { + error = _archiveError ?? "The panel is not inside an archive."; + return null; + } + + string folder; + try + { + folder = TempArea.CreateDirectory(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + error = e.Message; + return null; + } + + OperationResult result = _archive.Extract([ArchiveInnerPathOf(entry)], folder); + if (result.HasErrors || result.Cancelled) + { + error = result.FirstError?.Message ?? "The extraction was cancelled."; + TempArea.Delete(folder); + return null; + } + + return Path.Combine(folder, entry.Name); + } + private void EnterCurrentDirectory() { FileEntry? current = Current; - if (current is null || !current.IsDirectory) + if (current is null) { return; } + if (!current.IsDirectory) + { + // Ctrl+PgDn walks into an archive exactly as it walks into a folder. + if (_archiveFile is null && ArchiveFile.CanOpen(current.Name)) + { + Navigate(current.FullPath); + } + + return; + } + if (current.IsParent) { GoToParent(); @@ -1805,10 +1936,9 @@ private void JumpToQuickSearchMatch(int from) private void Load(string? focusName) { - DirectoryListing listing = FileSystemProvider.Read( - _path, - Settings.ShowHiddenFiles, - MakeComparer()); + DirectoryListing listing = _archiveFile is not null + ? ReadArchive() + : FileSystemProvider.Read(_path, Settings.ShowHiddenFiles, MakeComparer()); _entries = [.. listing.Entries]; _error = listing.Error; @@ -1816,6 +1946,107 @@ private void Load(string? focusName) FocusOn(focusName); } + /// + /// Works out whether a path lies inside an archive and, when it does, opens that archive - or + /// keeps the one already open when the path is still inside it, so walking around a big + /// tarball does not decompress it at every step. + /// + private void ResolveArchive(string path) + { + if (_archive is not null && ArchivePath.TryGetInner(path, _archive.FilePath, out string inner)) + { + _archiveInner = inner; + return; + } + + _archiveFile = null; + _archive = null; + _archiveInner = string.Empty; + _archiveError = null; + + if (FileSystemProvider.DirectoryExists(path) || + !ArchivePath.TrySplit(path, out string file, out string innerPath)) + { + return; + } + + OpenArchive(file, innerPath); + } + + private void OpenArchive(string file, string innerPath) + { + _archiveFile = file; + _archiveInner = innerPath; + _archiveError = null; + + try + { + _archive = ArchiveFile.Open(file); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or InvalidDataException or FormatException or NotSupportedException or ArgumentException) + { + _archive = null; + _archiveError = "Cannot read the archive: " + e.Message; + } + } + + /// + /// The listing of the folder inside the archive, built from its catalogue the way the file + /// system listing is built from the disk: ".." first, hidden entries filtered, sorted. + /// + private DirectoryListing ReadArchive() + { + DateTime stamp = _archive?.FileStamp ?? default; + var entries = new List + { + new() + { + Name = "..", + FullPath = FileSystemProvider.GetParent(_path) ?? WorkingDirectory, + IsDirectory = true, + IsParent = true, + Attributes = FileAttributes.Directory, + Modified = stamp, + Created = stamp, + Accessed = stamp, + }, + }; + + if (_archive is null) + { + return new DirectoryListing(_path, entries, _archiveError ?? "Cannot read the archive"); + } + + if (!_archive.IsDirectory(_archiveInner)) + { + return new DirectoryListing(_path, entries, $"Cannot find \"{_archiveInner}\" in the archive"); + } + + bool includeHidden = Settings.ShowHiddenFiles; + foreach (ArchiveItem item in _archive.List(_archiveInner)) + { + var entry = new FileEntry + { + Name = item.Name, + FullPath = ArchivePath.Combine(_archive.FilePath, item.Path), + IsDirectory = item.IsDirectory, + Size = item.Size, + Modified = item.Modified, + Created = item.Modified, + Accessed = item.Modified, + Attributes = item.Attributes == 0 ? FileAttributes.Normal : item.Attributes, + UnixExecutable = false, + }; + + if (includeHidden || !entry.IsHidden) + { + entries.Add(entry); + } + } + + return new DirectoryListing(_path, [.. entries.OrderBy(static e => e, MakeComparer())], null); + } + private void Resort() { FileEntry? current = Current; diff --git a/src/OpenStus/Program.cs b/src/OpenStus/Program.cs index 1cd8d6a..4d13aff 100644 --- a/src/OpenStus/Program.cs +++ b/src/OpenStus/Program.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using System.Text; +using OpenStus.Archives; using OpenStus.Core; using OpenStus.Rendering; @@ -151,7 +152,15 @@ internal static void WriteFrame(Stream stdout, string frame) /// The application's exit code. private static int Interactive(CommandLineArgs args) { - using var app = Application.Create(args); - return app.Run(); + try + { + using var app = Application.Create(args); + return app.Run(); + } + finally + { + // Whatever was taken out of archives to be viewed or run goes with the session. + TempArea.Cleanup(); + } } } diff --git a/src/OpenStus/Rendering/Terminal.cs b/src/OpenStus/Rendering/Terminal.cs index 9418abc..116001f 100644 --- a/src/OpenStus/Rendering/Terminal.cs +++ b/src/OpenStus/Rendering/Terminal.cs @@ -3,6 +3,34 @@ namespace OpenStus.Rendering; +/// +/// The hardware cursor's shape, as the DECSCUSR sequence (CSI n SP q) sets it; each value is +/// that sequence's parameter. +/// +public enum CursorShape +{ + /// Whatever the user configured for their terminal. + Default = 0, + + /// A blinking block. + BlinkingBlock = 1, + + /// A steady block. + SteadyBlock = 2, + + /// A blinking underline. + BlinkingUnderline = 3, + + /// A steady underline. + SteadyUnderline = 4, + + /// A blinking vertical bar - the text editing caret. + BlinkingBar = 5, + + /// A steady vertical bar. + SteadyBar = 6, +} + /// /// Owns the physical console: virtual terminal setup, the alternate screen buffer, the console /// modes, and the diff-based flush of a to stdout. @@ -36,11 +64,11 @@ public sealed class Terminal : IDisposable private const string Prologue = Esc + "[?1049h" + Esc + "[?25l" + Esc + "[?7l" + Esc + "[2J" + Esc + "[H"; // End any open synchronized update, turn mouse reporting off, autowrap back on, reset SGR, - // show the cursor, and only then leave the alternate buffer. + // put the user's own cursor shape back and show it, and only then leave the alternate buffer. private const string Epilogue = Esc + "[?2026l" + Esc + "[?1006l" + Esc + "[?1015l" + Esc + "[?1003l" + Esc + "[?1002l" + Esc + "[?1000l" + - Esc + "[?7h" + Esc + "[0m" + Esc + "[?25h" + Esc + "[?1049l"; + Esc + "[?7h" + Esc + "[0m" + Esc + "[0 q" + Esc + "[?25h" + Esc + "[?1049l"; private readonly object _sync = new(); private readonly bool _forcedSize; @@ -61,6 +89,8 @@ public sealed class Terminal : IDisposable private int _cursorY; private bool _cursorVisible; private bool _lastEmittedCursorVisible; + private CursorShape _cursorShape; + private CursorShape _lastEmittedShape; // The raw input mode captured by SuspendConsoleInputMode, so RestoreConsoleInputMode can put // back exactly what the input backend had established - mouse reporting bits included. @@ -322,11 +352,13 @@ public void ShowUserScreen() return; } - // End any open synchronized update, put autowrap and the colours back, show the - // cursor, and only then switch buffers - the same order the epilogue uses. - _writer.Write(Esc + "[?2026l" + Esc + "[?7h" + Esc + "[0m" + Esc + "[?25h" + Esc + "[?1049l"); + // End any open synchronized update, put autowrap, the colours and the user's cursor + // shape back, show the cursor, and only then switch buffers - the same order the + // epilogue uses. + _writer.Write(Esc + "[?2026l" + Esc + "[?7h" + Esc + "[0m" + Esc + "[0 q" + Esc + "[?25h" + Esc + "[?1049l"); _writer.Flush(); _altScreen = false; + _lastEmittedShape = CursorShape.Default; } } @@ -385,11 +417,19 @@ public void WriteUserScreenLine(string text) } /// Positions the hardware cursor, applied at the end of the next . - public void SetCursor(int x, int y, bool visible) + /// The column. + /// The row. + /// Whether the cursor is shown at all. + /// + /// The cursor's shape while shown; leaves it to the user's + /// terminal settings. Only a change of shape is written, so repeating one costs nothing. + /// + public void SetCursor(int x, int y, bool visible, CursorShape shape = CursorShape.Default) { _cursorX = x; _cursorY = y; _cursorVisible = visible; + _cursorShape = shape; } /// @@ -581,6 +621,13 @@ private void BuildFrame() int cx = Math.Clamp(_cursorX, 0, w - 1); int cy = Math.Clamp(_cursorY, 0, h - 1); sb.Append(Esc).Append('[').Append(cy + 1).Append(';').Append(cx + 1).Append('H'); + + if (_cursorShape != _lastEmittedShape) + { + sb.Append(Esc).Append('[').Append((int)_cursorShape).Append(" q"); + _lastEmittedShape = _cursorShape; + } + sb.Append(Esc).Append("[?25h"); } diff --git a/src/OpenStus/Text/Syntax/SyntaxRegistry.cs b/src/OpenStus/Text/Syntax/SyntaxRegistry.cs index d82170f..ba2107f 100644 --- a/src/OpenStus/Text/Syntax/SyntaxRegistry.cs +++ b/src/OpenStus/Text/Syntax/SyntaxRegistry.cs @@ -29,9 +29,28 @@ public static class SyntaxRegistry return Table.TryGetValue(extension, out SyntaxRules? rules) ? rules : null; } - /// Every language shipped, for the tests that walk them. + /// + /// The rules for a file, falling back to for a type no language + /// claims - how the editor colours numbers, strings and comment lines in a log, an ini or a + /// file with no extension at all. + /// + /// The file path or bare name. + /// The rules; never . + public static SyntaxRules ForPathOrPlainText(string? path) => ForPath(path) ?? PlainText; + + /// Every language shipped, plain text included, for the tests that walk them. public static IReadOnlyCollection All => - Table.Values.Distinct().ToArray(); + Table.Values.Append(PlainText).Distinct().ToArray(); + + /// The fallback for files of unknown type; see . + public static SyntaxRules PlainText { get; } = new() + { + Name = "Plain text", + Family = SyntaxFamily.PlainText, + Keywords = SyntaxRules.Words(caseSensitive: true, + "true", "false", "null", "True", "False", "None", "TRUE", "FALSE", "NULL", + "FATAL", "CRITICAL", "ERROR", "ERR", "WARNING", "WARN", "INFO", "DEBUG", "TRACE"), + }; // ------------------------------------------------------------------ the languages diff --git a/src/OpenStus/Text/Syntax/SyntaxRules.cs b/src/OpenStus/Text/Syntax/SyntaxRules.cs index 8cc9a29..739cbda 100644 --- a/src/OpenStus/Text/Syntax/SyntaxRules.cs +++ b/src/OpenStus/Text/Syntax/SyntaxRules.cs @@ -97,6 +97,13 @@ public enum SyntaxFamily : byte /// The CSV scanner: a white header row, then columns cycling through the colours. Csv, + + /// + /// The plain-text scanner for files no language claims: numbers, quoted strings, URLs, comment + /// lines, [section] headers and a few well-known words - whatever makes a log or a config + /// file readable without knowing its grammar. + /// + PlainText, } /// diff --git a/src/OpenStus/Text/Syntax/SyntaxTokenizer.cs b/src/OpenStus/Text/Syntax/SyntaxTokenizer.cs index 4a8eea2..ac287a2 100644 --- a/src/OpenStus/Text/Syntax/SyntaxTokenizer.cs +++ b/src/OpenStus/Text/Syntax/SyntaxTokenizer.cs @@ -53,6 +53,9 @@ public static SyntaxState TokenizeLine(string line, SyntaxRules rules, SyntaxSta case SyntaxFamily.Csv: return TokenizeCsv(line, n, state, tokens); + case SyntaxFamily.PlainText: + return TokenizePlainText(line, n, rules, tokens); + default: break; } @@ -437,6 +440,165 @@ private static int CloseSpanning(string line, int n, int from, string close, Tok return end; } + // ---------------------------------------------------------------- the plain-text scanner + + /// + /// Files no language claims, so the scanner has to be careful rather than clever: a whole + /// line starting with #, ; or // is a comment and a line that is only a + /// [section] a keyword; a quote makes a string only when it closes on the same line; + /// a URL is a string; a number - dates, times, versions and addresses included - counts only + /// when it stands on its own rather than inside a word; and the rules' words + /// (true, null, ERROR...) are keywords. Nothing carries to the next line. + /// + private static SyntaxState TokenizePlainText(string line, int n, SyntaxRules rules, List? tokens) + { + int i = 0; + while (i < n && char.IsWhiteSpace(line[i])) + { + i++; + } + + if (i < n && (line[i] is '#' or ';' || Matches(line, i, n, "//"))) + { + Emit(tokens, i, n - i, TokenKind.Comment); + return SyntaxState.None; + } + + if (i < n && line[i] == '[') + { + int close = line.IndexOf(']', i + 1, n - i - 1); + if (close > i + 1 && string.IsNullOrWhiteSpace(line[(close + 1)..n])) + { + Emit(tokens, i, close + 1 - i, TokenKind.Keyword); + return SyntaxState.None; + } + } + + while (i < n) + { + char c = line[i]; + bool boundary = i == 0 || !IsWordPart(line[i - 1]); + + if (c == '"' || (c == '\'' && boundary)) + { + int close = line.IndexOf(c, i + 1, n - i - 1); + if (close > i) + { + Emit(tokens, i, close + 1 - i, TokenKind.String); + i = close + 1; + continue; + } + + i++; + continue; + } + + if (boundary && IsUrlStart(line, i, n)) + { + int end = i; + while (end < n && !char.IsWhiteSpace(line[end]) && line[end] is not ('"' or '\'' or '<' or '>')) + { + end++; + } + + // Sentence punctuation after a link belongs to the sentence. + while (end > i && line[end - 1] is '.' or ',' or ';' or ':' or ')' or ']' or '!' or '?') + { + end--; + } + + Emit(tokens, i, end - i, TokenKind.String); + i = end; + continue; + } + + if (char.IsAsciiDigit(c) && boundary) + { + int end = ScanPlainNumber(line, n, i); + if (end >= n || !IsWordPart(line[end])) + { + Emit(tokens, i, end - i, TokenKind.Number); + } + + // A number running into letters ("5px", "3rd", a hash) is a word, left plain. + while (end < n && IsWordPart(line[end])) + { + end++; + } + + i = end; + continue; + } + + if (IsWordPart(c)) + { + int start = i; + while (i < n && IsWordPart(line[i])) + { + i++; + } + + if (boundary && rules.Keywords.Contains(line[start..i])) + { + Emit(tokens, start, i - start, TokenKind.Keyword); + } + + continue; + } + + i++; + } + + return SyntaxState.None; + } + + /// + /// A number in running text: hex after 0x, otherwise digits joined by the separators + /// dates, times, versions and addresses use - each only when a digit follows it, so the full + /// stop ending a sentence stays out. + /// + private static int ScanPlainNumber(string line, int n, int at) + { + int i = at; + if (i + 2 < n && line[i] == '0' && line[i + 1] is 'x' or 'X' && char.IsAsciiHexDigit(line[i + 2])) + { + i += 2; + while (i < n && char.IsAsciiHexDigit(line[i])) + { + i++; + } + + return i; + } + + while (i < n) + { + if (char.IsAsciiDigit(line[i])) + { + i++; + continue; + } + + if (line[i] is '.' or ',' or ':' or '-' or '/' && i + 1 < n && char.IsAsciiDigit(line[i + 1])) + { + i++; + continue; + } + + break; + } + + return i; + } + + private static bool IsUrlStart(string line, int at, int n) => + Matches(line, at, n, "http://") || + Matches(line, at, n, "https://") || + Matches(line, at, n, "ftp://") || + Matches(line, at, n, "file://"); + + private static bool IsWordPart(char c) => char.IsLetterOrDigit(c) || c == '_'; + // ---------------------------------------------------------------- the CSV scanner /// diff --git a/src/OpenStus/Theming/Theme.cs b/src/OpenStus/Theming/Theme.cs index f7958a9..2929240 100644 --- a/src/OpenStus/Theming/Theme.cs +++ b/src/OpenStus/Theming/Theme.cs @@ -80,6 +80,12 @@ public Rendering.Palette Palette /// An archive file. public CellStyle PanelArchive { get; set; } + /// A backup or temporary file (*.bak, *.tmp, *~...). + public CellStyle PanelTemporary { get; set; } + + /// An image, sound or video file. + public CellStyle PanelMedia { get; set; } + /// A file tagged with Ins. public CellStyle PanelSelectedFile { get; set; } @@ -340,8 +346,8 @@ public Rendering.Palette Palette /// /// The only difference from is the palette: a deeper /// #000080 blue and a pure #00FFFF cyan instead of the DOS #0000AA and - /// #55FFFF, which lifts the dominant panel pair from 10.84:1 to 12.77:1. Every one of the - /// 74 entries below is identical. + /// #55FFFF, which lifts the dominant panel pair from 10.84:1 to 12.77:1. Every entry of + /// the colour table is identical. /// public static Theme ClassicNt() => new() { Name = "Classic NT", Palette = Rendering.Palette.WindowsNt }; @@ -432,6 +438,11 @@ private static void ApplyClassic(Theme t) t.PanelExecutable = new CellStyle(lightGreen, bBlue); // executables t.PanelArchive = new CellStyle(ConsoleColor.Magenta, bBlue); // archives + // Backups and temporaries recede in brown, next to the hidden entries' dark cyan: they + // are leftovers, not the files you came for. Media stands apart in light red. + t.PanelTemporary = new CellStyle(ConsoleColor.DarkYellow, bBlue); // *.bak, *.tmp, *~ + t.PanelMedia = new CellStyle(ConsoleColor.Red, bBlue); // images, sound, video + t.PanelSelectedFile = new CellStyle(yellow, bBlue); // tagged entries t.PanelCursor = new CellStyle(black, bCyan); // cursor bar t.PanelCursorSelected = new CellStyle(yellow, bCyan); // cursor bar over a tagged entry @@ -552,6 +563,8 @@ private static void ApplyClassic(Theme t) new("PanelHidden", t => t.PanelHidden, (t, v) => t.PanelHidden = v), new("PanelExecutable", t => t.PanelExecutable, (t, v) => t.PanelExecutable = v), new("PanelArchive", t => t.PanelArchive, (t, v) => t.PanelArchive = v), + new("PanelTemporary", t => t.PanelTemporary, (t, v) => t.PanelTemporary = v), + new("PanelMedia", t => t.PanelMedia, (t, v) => t.PanelMedia = v), new("PanelSelectedFile", t => t.PanelSelectedFile, (t, v) => t.PanelSelectedFile = v), new("PanelCursor", t => t.PanelCursor, (t, v) => t.PanelCursor = v), new("PanelCursorSelected", t => t.PanelCursorSelected, (t, v) => t.PanelCursorSelected = v), diff --git a/src/OpenStus/Ui/CommandLine.cs b/src/OpenStus/Ui/CommandLine.cs index 753350f..9c45411 100644 --- a/src/OpenStus/Ui/CommandLine.cs +++ b/src/OpenStus/Ui/CommandLine.cs @@ -38,6 +38,13 @@ namespace OpenStus.Ui; /// truncated from the left when the current directory is too long, so the tail of the path - the /// part that actually tells you where you are - survives. /// +/// +/// Text can be selected, but only while the panels are hidden (Ctrl+O): there is no panel then to +/// claim Shift and the arrows, so the shell passes selectWithShift and Shift+Left, Shift+Right, +/// Shift+Home, Shift+End and their Ctrl+Shift word forms extend a selection, as in any edit field. +/// A selection is copied with Ctrl+C or Ctrl+Ins, cut with Shift+Del, removed by Backspace or Del, +/// and replaced by whatever is typed or pasted over it. +/// /// public sealed class CommandLine { @@ -55,6 +62,9 @@ public sealed class CommandLine private int _scroll; private string? _pendingLine; + // The fixed end of the selection, or -1 for none; the caret is the moving end. + private int _anchor = -1; + // Reverse search (Ctrl+R): the query typed so far, the history index of the current match or // -1, and the line as it was before the search began, so Escape can put it back. private bool _searching; @@ -111,6 +121,7 @@ public void StartReverseSearch() _searchOriginal = _text; _searchQuery = _text; _searchIndex = -1; + _anchor = -1; History.ResetCursor(); _pendingLine = null; @@ -141,6 +152,7 @@ public string Text { _text = value ?? string.Empty; _caret = _text.Length; + _anchor = -1; History.ResetCursor(); _pendingLine = null; } @@ -149,6 +161,23 @@ public string Text /// The caret position, from 0 to the length of . public int Caret => _caret; + /// while part of the line is selected. + public bool HasSelection => _anchor >= 0 && _anchor <= _text.Length && _anchor != _caret; + + /// The selected text; empty when nothing is selected. + public string SelectedText + { + get + { + (int start, int end) = SelectionRange; + return _text[start..end]; + } + } + + /// The selection as [Start, End); an empty range at the caret when there is none. + public (int Start, int End) SelectionRange => + HasSelection ? (Math.Min(_anchor, _caret), Math.Max(_anchor, _caret)) : (_caret, _caret); + /// when there is nothing on the line. public bool IsEmpty => _text.Length == 0; @@ -200,11 +229,29 @@ public void Draw(ScreenBuffer buf, int y) DrawColouring(buf, y, promptWidth, textWidth); DrawSuggestion(buf, y, promptWidth, textWidth); DrawSearchMatch(buf, y, promptWidth, textWidth); + DrawSelection(buf, y, promptWidth, textWidth); CaretY = y; CaretX = Math.Clamp(promptWidth + (_caret - _scroll), 0, Math.Max(0, width - 1)); } + /// Paints the selected part of the visible text over its colouring. + private void DrawSelection(ScreenBuffer buf, int y, int promptWidth, int textWidth) + { + if (!HasSelection) + { + return; + } + + (int start, int end) = SelectionRange; + int x0 = Math.Max(0, start - _scroll); + int x1 = Math.Min(textWidth, end - _scroll); + if (x1 > x0) + { + buf.FillStyle(new Rect(promptWidth + x0, y, x1 - x0, 1), _theme.CommandLineSelected); + } + } + /// Highlights where the reverse-search query sits inside the matched command. private void DrawSearchMatch(ScreenBuffer buf, int y, int promptWidth, int textWidth) { @@ -295,7 +342,7 @@ public void Draw(ScreenBuffer buf, int y, string currentDirectory) /// /// Inserts text at the caret, which is where Ctrl+Enter, Ctrl+F and the other "put the file name - /// on the command line" bindings land. + /// on the command line" bindings land. A selection is replaced by it. /// /// The text to insert; or empty does nothing. public void Insert(string? text) @@ -305,6 +352,7 @@ public void Insert(string? text) return; } + DeleteSelection(); _text = _text.Insert(_caret, text); _caret += text.Length; History.ResetCursor(); @@ -317,26 +365,127 @@ public void Clear() _text = string.Empty; _caret = 0; _scroll = 0; + _anchor = -1; + History.ResetCursor(); + _pendingLine = null; + } + + /// + /// The keys that make and use a selection. Shift+motion only extends it when + /// says the panels have let go of those keys; copying works + /// whenever there is something selected. + /// + private bool HandleSelectionKey(KeyEvent key, bool selectWithShift) + { + bool shift = (key.Mods & KeyMods.Shift) != 0; + bool ctrl = (key.Mods & KeyMods.Ctrl) != 0; + bool alt = (key.Mods & KeyMods.Alt) != 0; + + if (alt) + { + return false; + } + + if (selectWithShift && shift) + { + switch (key.Key) + { + case ConsoleKey.LeftArrow: + ExtendSelection(ctrl ? PreviousWord(_caret) : _caret - 1); + return true; + + case ConsoleKey.RightArrow: + ExtendSelection(ctrl ? NextWord(_caret) : _caret + 1); + return true; + + case ConsoleKey.Home when !ctrl: + ExtendSelection(0); + return true; + + case ConsoleKey.End when !ctrl: + ExtendSelection(_text.Length); + return true; + } + } + + if (selectWithShift && key.Mods == KeyMods.Ctrl && key.Key == ConsoleKey.A && !IsEmpty) + { + _anchor = 0; + _caret = _text.Length; + return true; + } + + if (HasSelection && key.Mods == KeyMods.Ctrl && key.Key is ConsoleKey.C or ConsoleKey.Insert) + { + Clipboard.SetText(SelectedText); + return true; + } + + if (HasSelection && key.Mods == KeyMods.Shift && key.Key == ConsoleKey.Delete) + { + Clipboard.SetText(SelectedText); + DeleteSelection(); + return true; + } + + return false; + } + + /// Moves the caret and keeps the anchor where the selection started. + private void ExtendSelection(int position) + { + if (_anchor < 0 || _anchor > _text.Length) + { + _anchor = _caret; + } + + _caret = Math.Clamp(position, 0, _text.Length); History.ResetCursor(); _pendingLine = null; } + /// Removes the selected text and leaves the caret where it began. + /// when there was a selection to remove. + private bool DeleteSelection() + { + if (!HasSelection) + { + _anchor = -1; + return false; + } + + (int start, int end) = SelectionRange; + _text = _text.Remove(start, end - start); + _anchor = -1; + MoveCaret(start); + return true; + } + /// /// Handles one key press. /// /// The key press. /// The application context, used to run the command on Enter. + /// + /// Whether Shift plus a motion key selects text. The shell sets it while the panels are hidden + /// (Ctrl+O); otherwise those chords are the panel's select-and-move family and go back to it. + /// /// /// when the command line consumed the key, when /// the panel should get it instead. /// - public bool HandleKey(KeyEvent key, IAppContext ctx) + public bool HandleKey(KeyEvent key, IAppContext ctx, bool selectWithShift = false) { if (_searching && HandleSearchKey(key)) { return true; } + if (HandleSelectionKey(key, selectWithShift)) + { + return true; + } + bool empty = IsEmpty; // Ctrl chords first: several of them work whether or not the line has text, and the rest @@ -381,11 +530,19 @@ public bool HandleKey(KeyEvent key, IAppContext ctx) return true; case ConsoleKey.Backspace when !empty: - DeleteRange(PreviousWord(_caret), _caret); + if (!DeleteSelection()) + { + DeleteRange(PreviousWord(_caret), _caret); + } + return true; case ConsoleKey.Delete when !empty: - DeleteRange(_caret, NextWord(_caret)); + if (!DeleteSelection()) + { + DeleteRange(_caret, NextWord(_caret)); + } + return true; default: @@ -427,6 +584,11 @@ public bool HandleKey(KeyEvent key, IAppContext ctx) return CompletePath(ctx); case ConsoleKey.Backspace: + if (DeleteSelection()) + { + return true; + } + if (empty || _caret == 0) { return !empty; @@ -442,6 +604,11 @@ public bool HandleKey(KeyEvent key, IAppContext ctx) return false; } + if (DeleteSelection()) + { + return true; + } + if (_caret < _text.Length) { _text = _text.Remove(_caret, 1); @@ -680,6 +847,7 @@ private void AcceptSuggestion(string suggestion, bool wholeLine) _text = suggestion[..end]; _caret = _text.Length; + _anchor = -1; History.ResetCursor(); _pendingLine = null; } @@ -777,6 +945,7 @@ private void ReplaceToken(int start, int length, string replacement) { _text = string.Concat(_text.AsSpan(0, start), replacement, _text.AsSpan(start + length)); _caret = start + replacement.Length; + _anchor = -1; History.ResetCursor(); _pendingLine = null; } @@ -839,11 +1008,13 @@ private void SetRecalled(string entry) { _text = entry; _caret = entry.Length; + _anchor = -1; } private void MoveCaret(int position) { _caret = Math.Clamp(position, 0, _text.Length); + _anchor = -1; AfterEdit(); } diff --git a/src/OpenStus/Ui/HelpScreen.cs b/src/OpenStus/Ui/HelpScreen.cs index 8c318ff..f714c67 100644 --- a/src/OpenStus/Ui/HelpScreen.cs +++ b/src/OpenStus/Ui/HelpScreen.cs @@ -223,8 +223,8 @@ private void DrawScrollBar(ScreenBuffer buffer, Rect area, int firstRow, int row ("Panels", "Left / Right", "Move one column, or edit the command line"), ("Panels", "PgUp / PgDn", "Scroll one page"), ("Panels", "Home / End", "First / last item"), - ("Panels", "Enter", "Enter a folder, or run the file under the cursor"), - ("Panels", "Ctrl+PgDn", "Enter the folder under the cursor"), + ("Panels", "Enter", "Enter a folder or an archive, or run the file under the cursor"), + ("Panels", "Ctrl+PgDn", "Enter the folder or archive under the cursor"), ("Panels", "Ctrl+PgUp", "Go to the parent folder"), ("Panels", "Ctrl+\\", "Go to the root of the current drive"), ("Panels", "Tab", "Switch the active panel"), @@ -235,7 +235,7 @@ private void DrawScrollBar(ScreenBuffer buffer, Rect area, int firstRow, int row ("Panels", "Ctrl+F1 / Ctrl+F2", "Hide or show the left / right panel"), ("Panels", "Ctrl+H", "Show or hide hidden and system files"), ("Panels", "Ctrl+B", "Show or hide the function key bar"), - ("Panels", "Alt+F1 / Alt+F2", "Change the drive of the left / right panel"), + ("Panels", "Alt+F1 / Alt+F2", "Change the drive of the left / right panel; the other panel's folder when it is on that drive"), ("Panels", "Alt+", "Quick search by name"), ("Panels", "Ctrl+T", "Open a new tab on the current folder"), ("Panels", "Ctrl+W", "Close the current tab"), @@ -309,6 +309,7 @@ private void DrawScrollBar(ScreenBuffer buffer, Rect area, int firstRow, int row ("Editor", "Shift+arrows", "Select text"), ("Editor", "Ctrl+Y", "Delete the current line"), ("Editor", "F2", "Save"), + ("Editor", "F5", "Switch syntax colouring off or on"), ("Editor", "F7", "Search"), ("Editor", "Shift+F7", "Search again"), ("Editor", "F10 / Esc", "Close the editor"), @@ -323,6 +324,8 @@ private void DrawScrollBar(ScreenBuffer buffer, Rect area, int firstRow, int row ("Command line", "Right / End", "Accept the grey suggestion after the caret"), ("Command line", "Tab", "Complete the file name under the caret; a list when several match"), ("Command line", "Ctrl+Left / Ctrl+Right", "Move one word"), + ("Command line", "Shift+Left / Shift+Right", "Select text while the panels are hidden (Ctrl+O); Shift+Home / End and Ctrl+Shift+arrows too"), + ("Command line", "Ctrl+C / Ctrl+Ins", "Copy the selected text; Shift+Del cuts it"), ("Command line", "Ctrl+Backspace / Ctrl+Del", "Delete one word"), ("Command line", "Ctrl+Enter / Ctrl+J", "Insert the name under the cursor"), ("Command line", "Ctrl+F", "Insert the full name under the cursor"), diff --git a/tests/OpenStus.Tests/ArchiveTests.cs b/tests/OpenStus.Tests/ArchiveTests.cs new file mode 100644 index 0000000..3350d7e --- /dev/null +++ b/tests/OpenStus.Tests/ArchiveTests.cs @@ -0,0 +1,584 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Text; +using OpenStus.Archives; +using OpenStus.Core; +using OpenStus.Files; +using OpenStus.Input; +using OpenStus.Operations; +using OpenStus.Panels; +using OpenStus.Rendering; +using OpenStus.Theming; + +namespace OpenStus.Tests; + +/// A scratch folder holding archives built for one test. +internal sealed class ArchiveTree : IDisposable +{ + public ArchiveTree(string name) + { + Root = Path.Combine(Path.GetTempPath(), "oc-tests", name + "-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(Root); + } + + public string Root { get; } + + /// Writes a zip holding the given entries; a name ending in '/' is a folder entry. + public string Zip(string name, params (string Entry, string? Text)[] entries) + { + string path = Path.Combine(Root, name); + using ZipArchive zip = ZipFile.Open(path, ZipArchiveMode.Create); + foreach ((string entry, string? text) in entries) + { + ZipArchiveEntry created = zip.CreateEntry(entry); + created.LastWriteTime = new DateTimeOffset(2024, 3, 15, 10, 30, 0, TimeSpan.Zero); + if (text is not null) + { + using var writer = new StreamWriter(created.Open(), new UTF8Encoding(false)); + writer.Write(text); + } + } + + return path; + } + + /// Builds a folder tree and packs it as a tar, gzip'd when asked. + public string Tar(string name, bool gzip, params (string Path, string Text)[] files) + { + string source = Path.Combine(Root, "src-" + Guid.NewGuid().ToString("N")[..6]); + foreach ((string file, string text) in files) + { + string full = Path.Combine(source, file.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, text); + } + + string path = Path.Combine(Root, name); + using (FileStream output = File.Create(path)) + using (Stream stream = gzip ? new GZipStream(output, CompressionLevel.Fastest) : output) + { + TarFile.CreateFromDirectory(source, stream, includeBaseDirectory: false); + } + + Directory.Delete(source, recursive: true); + return path; + } + + public string Folder(string name) + { + string path = Path.Combine(Root, name); + Directory.CreateDirectory(path); + return path; + } + + public void Dispose() + { + try + { + Directory.Delete(Root, recursive: true); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + // A leftover temp folder is not worth failing a test over. + } + } +} + +/// An input backend that plays back a fixed list of events, and fails a test that waits for more. +internal sealed class ScriptedInput(params InputEvent[] events) : IInputBackend +{ + private readonly Queue _events = new(events); + private int _idle; + private bool _yield; + + public KeyMods CurrentModifiers => KeyMods.None; + + public bool SupportsMouse => false; + + public bool SupportsModifierTracking => false; + + public int Remaining => _events.Count; + + public static InputEvent Key(ConsoleKey key, KeyMods mods = KeyMods.None) => + InputEvent.FromKey(new KeyEvent(key, '\0', mods)); + + public bool TryRead(out InputEvent ev) + { + // One event per pump, the way a person types: the loop drains everything available in one + // go, so a second key queued behind the one closing a dialog would reach that dialog again. + if (_yield) + { + _yield = false; + ev = default; + return false; + } + + if (_events.TryDequeue(out ev)) + { + _idle = 0; + _yield = true; + return true; + } + + // A dialog nobody scripted an answer for would otherwise spin the test forever. + if (++_idle > 400) + { + throw new InvalidOperationException("The script ran out while a dialog was still waiting."); + } + + return false; + } + + public void Dispose() + { + } +} + +public class ArchiveFileTests +{ + [Fact] + public void AZipCatalogueSynthesisesTheFoldersItOnlyImplies() + { + using var tree = new ArchiveTree("zip-catalogue"); + string zip = tree.Zip( + "tools.zip", + ("readme.txt", "hello"), + ("bin/run.cmd", "@echo off"), + ("docs/guide/intro.md", "# intro"), + ("empty/", null)); + + ArchiveFile archive = ArchiveFile.Open(zip); + + Assert.Equal(ArchiveKind.Zip, archive.Kind); + Assert.Equal(["bin", "docs", "empty", "readme.txt"], archive.List(string.Empty).Select(static i => i.Name).Order(StringComparer.Ordinal)); + Assert.True(archive.IsDirectory("docs/guide")); + Assert.Equal("intro.md", Assert.Single(archive.List("docs/guide")).Name); + Assert.Empty(archive.List("empty")); + Assert.Equal(5, archive.Find("readme.txt")!.Size); + Assert.False(archive.IsDirectory("readme.txt")); + + DirectorySize docs = archive.Measure("docs"); + Assert.Equal((7L, 1L, 1L), (docs.Bytes, docs.Files, docs.Directories)); + } + + [Theory] + [InlineData("a\\b.txt", "a/b.txt")] + [InlineData("./a//b/./c.txt", "a/b/c.txt")] + [InlineData("dir/", "dir")] + [InlineData("../evil.txt", null)] + [InlineData("a/../../evil.txt", null)] + [InlineData("/etc/passwd", null)] + [InlineData("C:/Windows/win.ini", null)] + [InlineData("", null)] + public void EntryNamesAreSanitised(string name, string? expected) => + Assert.Equal(expected, ArchiveFile.Sanitize(name)); + + [Fact] + public void AnEntryThatClimbsOutIsNeverListedNorExtracted() + { + using var tree = new ArchiveTree("zip-slip"); + string zip = tree.Zip("evil.zip", ("../evil.txt", "boom"), ("safe.txt", "ok")); + string target = tree.Folder("out"); + + ArchiveFile archive = ArchiveFile.Open(zip); + Assert.Equal("safe.txt", Assert.Single(archive.Items).Name); + + OperationsResult(archive.Extract(["safe.txt", "../evil.txt"], target)); + + Assert.True(File.Exists(Path.Combine(target, "safe.txt"))); + Assert.False(File.Exists(Path.Combine(tree.Root, "evil.txt"))); + } + + [Fact] + public void ExtractingAFolderKeepsItsShapeAndTheTimeStamps() + { + using var tree = new ArchiveTree("zip-extract"); + string zip = tree.Zip( + "site.zip", + ("site/index.html", "

hi

"), + ("site/css/main.css", "body{}"), + ("site/empty/", null), + ("other.txt", "not asked for")); + string target = tree.Folder("out"); + + ArchiveFile archive = ArchiveFile.Open(zip); + OperationsResult(archive.Extract(["site"], target)); + + Assert.Equal("

hi

", File.ReadAllText(Path.Combine(target, "site", "index.html"))); + Assert.Equal("body{}", File.ReadAllText(Path.Combine(target, "site", "css", "main.css"))); + Assert.True(Directory.Exists(Path.Combine(target, "site", "empty"))); + Assert.False(File.Exists(Path.Combine(target, "other.txt"))); + // A zip records wall-clock time, which is what the extracted file carries. + Assert.Equal( + new DateTime(2024, 3, 15, 10, 30, 0), + File.GetLastWriteTime(Path.Combine(target, "site", "index.html"))); + } + + [Fact] + public void ExtractingFromASubfolderDropsThePathAboveIt() + { + using var tree = new ArchiveTree("zip-inner"); + string zip = tree.Zip("deep.zip", ("a/b/c.txt", "c"), ("a/b/d/e.txt", "e")); + string target = tree.Folder("out"); + + OperationsResult(ArchiveFile.Open(zip).Extract(["a/b/c.txt", "a/b/d"], target)); + + Assert.Equal("c", File.ReadAllText(Path.Combine(target, "c.txt"))); + Assert.Equal("e", File.ReadAllText(Path.Combine(target, "d", "e.txt"))); + } + + [Theory] + [InlineData("pack.tar", false)] + [InlineData("pack.tar.gz", true)] + [InlineData("pack.tgz", true)] + public void TarAndGzippedTarAreReadAndExtracted(string name, bool gzip) + { + using var tree = new ArchiveTree("tar"); + string tar = tree.Tar(name, gzip, ("src/main.c", "int main;"), ("README", "read me")); + string target = tree.Folder("out"); + + ArchiveFile archive = ArchiveFile.Open(tar); + Assert.Equal(gzip ? ArchiveKind.TarGzip : ArchiveKind.Tar, archive.Kind); + Assert.True(archive.IsDirectory("src")); + Assert.Equal(9, archive.Find("src/main.c")!.Size); + + OperationsResult(archive.Extract(["src", "README"], target)); + Assert.Equal("int main;", File.ReadAllText(Path.Combine(target, "src", "main.c"))); + Assert.Equal("read me", File.ReadAllText(Path.Combine(target, "README"))); + } + + [Fact] + public void ALoneGzipHoldsOneFileNamedAfterIt() + { + using var tree = new ArchiveTree("gz"); + string gz = Path.Combine(tree.Root, "server.log.gz"); + using (var output = new GZipStream(File.Create(gz), CompressionLevel.Fastest)) + { + output.Write(Encoding.UTF8.GetBytes("line one\nline two\n")); + } + + ArchiveFile archive = ArchiveFile.Open(gz); + ArchiveItem item = Assert.Single(archive.Items); + Assert.Equal("server.log", item.Name); + Assert.Equal(18, item.Size); + + string target = tree.Folder("out"); + OperationsResult(archive.Extract(["server.log"], target)); + Assert.Equal("line one\nline two\n", File.ReadAllText(Path.Combine(target, "server.log"))); + } + + [Theory] + [InlineData("a.zip", true)] + [InlineData("A.JAR", true)] + [InlineData("pkg.nupkg", true)] + [InlineData("x.tar", true)] + [InlineData("x.tar.gz", true)] + [InlineData("x.tgz", true)] + [InlineData("x.7z", false)] + [InlineData("x.rar", false)] + [InlineData("x.txt", false)] + [InlineData(null, false)] + public void OnlyTheFormatsTheRuntimeReadsCanBeOpened(string? name, bool expected) => + Assert.Equal(expected, ArchiveFile.CanOpen(name)); + + [Fact] + public void ACorruptArchiveThrowsAnInvalidDataError() + { + using var tree = new ArchiveTree("corrupt"); + string zip = Path.Combine(tree.Root, "broken.zip"); + File.WriteAllText(zip, "this is not a zip"); + + Assert.Throws(() => ArchiveFile.Open(zip)); + } + + private static void OperationsResult(OperationResult result) => + Assert.False(result.HasErrors, string.Join("; ", result.Errors.Select(static e => e.ToString()))); +} + +public class ArchivePathTests +{ + [Fact] + public void APathRunningThroughAnArchiveSplitsAtTheArchiveFile() + { + using var tree = new ArchiveTree("split"); + string zip = tree.Zip("a.zip", ("x/y.txt", "y")); + + Assert.True(ArchivePath.TrySplit(Path.Combine(zip, "x", "y.txt"), out string file, out string inner)); + Assert.Equal(zip, file); + Assert.Equal("x/y.txt", inner); + + Assert.True(ArchivePath.TrySplit(zip, out _, out string root)); + Assert.Equal(string.Empty, root); + + Assert.False(ArchivePath.TrySplit(tree.Root, out _, out _)); + Assert.False(ArchivePath.TrySplit(Path.Combine(tree.Root, "missing", "deeper"), out _, out _)); + } + + [Fact] + public void AnOrdinaryFileIsNoArchive() + { + using var tree = new ArchiveTree("plain"); + string text = Path.Combine(tree.Root, "notes.txt"); + File.WriteAllText(text, "x"); + + Assert.False(ArchivePath.TrySplit(Path.Combine(text, "inside"), out _, out _)); + } + + [Fact] + public void CombineAndTryGetInnerAreInverses() + { + string archive = Path.Combine(Path.GetTempPath(), "pack.zip"); + string combined = ArchivePath.Combine(archive, "a/b/c.txt"); + + Assert.Equal(Path.Combine(archive, "a", "b", "c.txt"), combined); + Assert.True(ArchivePath.TryGetInner(combined, archive, out string inner)); + Assert.Equal("a/b/c.txt", inner); + Assert.True(ArchivePath.TryGetInner(archive, archive, out string top)); + Assert.Equal(string.Empty, top); + Assert.False(ArchivePath.TryGetInner(archive + "x", archive, out _)); + } +} + +public class ArchivePanelTests +{ + private static FilePanel Panel(string path) + { + var panel = new FilePanel(null, Theme.Classic(), isLeft: true) { Bounds = new Rect(0, 0, 60, 20) }; + panel.Navigate(path); + return panel; + } + + private static void Focus(FilePanel panel, string name) => + panel.CursorIndex = panel.Entries.ToList().FindIndex(e => e.Name == name); + + private static void Press(FilePanel panel, ConsoleKey key, KeyMods mods = KeyMods.None) => + panel.HandleKey(new KeyEvent(key, '\0', mods), null!); + + [Fact] + public void EnterWalksIntoAnArchiveAndBackOutOfIt() + { + using var tree = new ArchiveTree("panel-enter"); + string zip = tree.Zip("tools.zip", ("bin/run.cmd", "@echo off"), ("readme.txt", "hello")); + FilePanel panel = Panel(tree.Root); + + Focus(panel, "tools.zip"); + Press(panel, ConsoleKey.Enter); + + Assert.True(panel.InArchive); + Assert.Equal(zip, panel.CurrentPath); + Assert.Equal(tree.Root, panel.WorkingDirectory); + Assert.Equal(["..", "bin", "readme.txt"], panel.Entries.Select(static e => e.Name)); + Assert.Null(panel.Error); + + Focus(panel, "bin"); + Press(panel, ConsoleKey.Enter); + Assert.Equal(Path.Combine(zip, "bin"), panel.CurrentPath); + Assert.Equal("bin", panel.ArchiveInnerPath); + Assert.Equal("run.cmd", panel.Entries[1].Name); + + // Up twice: to the archive's root, then out to the folder holding it, cursor on the archive. + Press(panel, ConsoleKey.Backspace); + Assert.Equal(zip, panel.CurrentPath); + Press(panel, ConsoleKey.Backspace); + + Assert.False(panel.InArchive); + Assert.Equal(tree.Root, panel.CurrentPath); + Assert.Equal("tools.zip", panel.Current!.Name); + } + + [Fact] + public void CtrlPgDnEntersAnArchiveToo() + { + using var tree = new ArchiveTree("panel-ctrl-pgdn"); + string zip = tree.Zip("a.zip", ("x.txt", "x")); + FilePanel panel = Panel(tree.Root); + + Focus(panel, "a.zip"); + Press(panel, ConsoleKey.PageDown, KeyMods.Ctrl); + + Assert.True(panel.InArchive); + Assert.Equal(zip, panel.CurrentPath); + } + + [Fact] + public void NavigatingStraightToAPathInsideAnArchiveOpensIt() + { + using var tree = new ArchiveTree("panel-direct"); + string zip = tree.Zip("a.zip", ("deep/er/file.txt", "x")); + + FilePanel panel = Panel(Path.Combine(zip, "deep", "er")); + + Assert.True(panel.InArchive); + Assert.Equal("deep/er", panel.ArchiveInnerPath); + Assert.Equal("file.txt", panel.Entries[1].Name); + Assert.Equal(Path.Combine(zip, "deep"), panel.Entries[0].FullPath); + } + + [Fact] + public void AnUnreadableArchiveSaysSoAndOffersTheWayBack() + { + using var tree = new ArchiveTree("panel-corrupt"); + string zip = Path.Combine(tree.Root, "broken.zip"); + File.WriteAllText(zip, "garbage"); + + FilePanel panel = Panel(zip); + + Assert.True(panel.InArchive); + Assert.NotNull(panel.Error); + Assert.StartsWith("Cannot read the archive", panel.Error, StringComparison.Ordinal); + Assert.Equal(tree.Root, Assert.Single(panel.Entries).FullPath); + } + + [Fact] + public void ExtractToTempTakesOneFileOut() + { + using var tree = new ArchiveTree("panel-temp"); + string zip = tree.Zip("a.zip", ("notes/today.txt", "remember")); + FilePanel panel = Panel(Path.Combine(zip, "notes")); + + string? extracted = panel.ExtractToTemp(panel.Entries[1], out string? error); + try + { + Assert.Null(error); + Assert.NotNull(extracted); + Assert.Equal("today.txt", Path.GetFileName(extracted)); + Assert.Equal("remember", File.ReadAllText(extracted)); + } + finally + { + TempArea.Delete(Path.GetDirectoryName(extracted)); + } + } +} + +/// The shell's side of archives: copying out, refusing to write in. +public class ArchiveShellTests +{ + private static Application Build(IInputBackend input, string left, string right) + { + Terminal terminal = Terminal.Create(120, 40); + var app = new Application(terminal, new Settings { ShowClock = false, ConfirmDelete = true }, Theme.Classic(), input); + app.Initialize(new CommandLineArgs { LeftPath = left, RightPath = right }); + return app; + } + + private static void Press(Application app, ConsoleKey key, KeyMods mods = KeyMods.None) => + app.ProcessInput(ScriptedInput.Key(key, mods)); + + private static void Focus(FilePanel panel, string name) => + panel.CursorIndex = panel.Entries.ToList().FindIndex(e => e.Name == name); + + [Fact] + public void F5CopiesAFolderOutOfAnArchive() + { + using var tree = new ArchiveTree("shell-copy"); + string zip = tree.Zip("site.zip", ("site/index.html", "

hi

"), ("site/css/main.css", "body{}")); + string target = tree.Folder("target"); + + // Enter accepts the destination the copy dialog offers: the other panel's folder. + var input = new ScriptedInput(ScriptedInput.Key(ConsoleKey.Enter)); + using Application app = Build(input, zip, target); + Focus(app.LeftFilePanel, "site"); + + Press(app, ConsoleKey.F5); + + Assert.Equal(0, input.Remaining); + Assert.Equal("

hi

", File.ReadAllText(Path.Combine(target, "site", "index.html"))); + Assert.Equal("body{}", File.ReadAllText(Path.Combine(target, "site", "css", "main.css"))); + Assert.Contains(app.RightFilePanel.Entries, static e => e.Name == "site"); + } + + [Fact] + public void DeletingInsideAnArchiveIsRefused() + { + using var tree = new ArchiveTree("shell-delete"); + string zip = tree.Zip("a.zip", ("keep.txt", "keep")); + long before = new FileInfo(zip).Length; + + // One Enter dismisses the "read-only" message. + var input = new ScriptedInput(ScriptedInput.Key(ConsoleKey.Enter)); + using Application app = Build(input, zip, tree.Root); + Focus(app.LeftFilePanel, "keep.txt"); + + Press(app, ConsoleKey.F8); + + Assert.Equal(0, input.Remaining); + Assert.Equal(before, new FileInfo(zip).Length); + Assert.Contains(app.LeftFilePanel.Entries, static e => e.Name == "keep.txt"); + } + + [Fact] + public void CopyingIntoAnArchiveIsRefused() + { + using var tree = new ArchiveTree("shell-into"); + string source = tree.Folder("source"); + File.WriteAllText(Path.Combine(source, "new.txt"), "new"); + string zip = tree.Zip("a.zip", ("old.txt", "old")); + long before = new FileInfo(zip).Length; + + // Enter accepts the archive as the destination, a second Enter dismisses the refusal. + var input = new ScriptedInput(ScriptedInput.Key(ConsoleKey.Enter), ScriptedInput.Key(ConsoleKey.Enter)); + using Application app = Build(input, source, zip); + Focus(app.LeftFilePanel, "new.txt"); + + Press(app, ConsoleKey.F5); + + Assert.Equal(0, input.Remaining); + Assert.Equal(before, new FileInfo(zip).Length); + Assert.True(File.Exists(zip)); + } +} + +public class DriveMenuTests +{ + private static DriveList.DriveItem Drive(string root) => + new(root, string.Empty, "NTFS", DriveType.Fixed, 100, 50, true); + + [Fact] + public void PickingTheOtherPanelsDriveLandsOnTheOtherPanelsFolder() + { + DriveList.DriveItem[] drives = [Drive(@"C:\"), Drive(@"D:\")]; + + Assert.Equal(@"D:\Work\db", Application.DriveTarget(drives, 1, @"D:\Work\db")); + Assert.Equal(@"C:\", Application.DriveTarget(drives, 0, @"D:\Work\db")); + Assert.Equal(@"C:\", Application.DriveTarget(drives, 0, null)); + } + + [Fact] + public void TheLongestMountPointOwnsAPath() + { + DriveList.DriveItem[] drives = [Drive("/"), Drive("/mnt/data")]; + + Assert.Equal("/mnt/data/logs", Application.DriveTarget(drives, 1, "/mnt/data/logs")); + Assert.Equal("/", Application.DriveTarget(drives, 0, "/mnt/data/logs")); + Assert.Equal("/home/me", Application.DriveTarget(drives, 0, "/home/me")); + } +} + +/// Ctrl+O leaves the command line live, and with no panel to claim them Shift and the arrows select. +public class HiddenPanelsSelectionTests +{ + [Fact] + public void ShiftArrowsSelectOnTheCommandLineOnceCtrlOHidThePanels() + { + using var tree = new ShellTree("ctrl-o-select"); + Terminal terminal = Terminal.Create(120, 40); + using var app = new Application(terminal, new Settings { ShowClock = false }, Theme.Classic(), input: null); + app.Initialize(new CommandLineArgs { LeftPath = tree.Root, RightPath = tree.Root }); + + app.ProcessInput(ScriptedInput.Key(ConsoleKey.O, KeyMods.Ctrl)); + Assert.True(app.PanelsHidden); + + app.CommandLineWidget.Text = "echo hello"; + int tagged = app.LeftFilePanel.Entries.Count(static e => e.Selected); + + for (int i = 0; i < 5; i++) + { + app.ProcessInput(ScriptedInput.Key(ConsoleKey.LeftArrow, KeyMods.Shift)); + } + + Assert.Equal("hello", app.CommandLineWidget.SelectedText); + Assert.Equal(tagged, app.LeftFilePanel.Entries.Count(static e => e.Selected)); + Assert.True(app.PanelsHidden); + } +} diff --git a/tests/OpenStus.Tests/CommandLineTests.cs b/tests/OpenStus.Tests/CommandLineTests.cs index 31475d1..6b6df51 100644 --- a/tests/OpenStus.Tests/CommandLineTests.cs +++ b/tests/OpenStus.Tests/CommandLineTests.cs @@ -651,6 +651,169 @@ public void UpOnAnEmptyLineStillBelongsToThePanel() } } +/// +/// Selecting text on the command line. Shift and the arrows only select while the panels are hidden +/// (Ctrl+O), which the shell signals with selectWithShift; otherwise they stay the panel's. +/// +public class CommandLineSelectionTests +{ + private static (CommandLine Line, RecordingContext Ctx, MemoryClipboard Clipboard) New(string typed) + { + var ctx = new RecordingContext(); + var clipboard = new MemoryClipboard(); + var line = new CommandLine(ctx.Theme, new CommandHistory()) { Clipboard = clipboard }; + Keys.Type(line, ctx, typed); + return (line, ctx, clipboard); + } + + private static bool Hidden(CommandLine line, RecordingContext ctx, ConsoleKey key, KeyMods mods) => + line.HandleKey(Keys.Key(key, mods), ctx, selectWithShift: true); + + [Fact] + public void ShiftLeftSelectsWhileThePanelsAreHidden() + { + (CommandLine line, RecordingContext ctx, _) = New("copy a.txt b.txt"); + + for (int i = 0; i < 5; i++) + { + Assert.True(Hidden(line, ctx, ConsoleKey.LeftArrow, KeyMods.Shift)); + } + + Assert.True(line.HasSelection); + Assert.Equal("b.txt", line.SelectedText); + Assert.Equal(11, line.Caret); + Assert.Equal("copy a.txt b.txt", line.Text); + } + + [Fact] + public void WithThePanelsUpShiftLeftStillGoesToThePanel() + { + (CommandLine line, RecordingContext ctx, _) = New("copy a.txt"); + + Assert.False(line.HandleKey(Keys.Key(ConsoleKey.LeftArrow, KeyMods.Shift), ctx)); + Assert.False(line.HasSelection); + } + + [Fact] + public void ShiftRightShiftHomeAndShiftEndExtendFromTheSameAnchor() + { + (CommandLine line, RecordingContext ctx, _) = New("dir /s"); + line.HandleKey(Keys.Key(ConsoleKey.Home), ctx); + + Hidden(line, ctx, ConsoleKey.RightArrow, KeyMods.Shift); + Hidden(line, ctx, ConsoleKey.RightArrow, KeyMods.Shift); + Assert.Equal("di", line.SelectedText); + + Hidden(line, ctx, ConsoleKey.End, KeyMods.Shift); + Assert.Equal("dir /s", line.SelectedText); + + Hidden(line, ctx, ConsoleKey.Home, KeyMods.Shift); + Assert.False(line.HasSelection); // back at the anchor: nothing between + } + + [Fact] + public void CtrlShiftArrowsSelectWholeWords() + { + (CommandLine line, RecordingContext ctx, _) = New("git commit --amend"); + + Hidden(line, ctx, ConsoleKey.LeftArrow, KeyMods.Ctrl | KeyMods.Shift); + Assert.Equal("--amend", line.SelectedText); + } + + [Fact] + public void TypingReplacesTheSelection() + { + (CommandLine line, RecordingContext ctx, _) = New("type old.txt"); + for (int i = 0; i < 7; i++) + { + Hidden(line, ctx, ConsoleKey.LeftArrow, KeyMods.Shift); + } + + Keys.Type(line, ctx, "new.md"); + + Assert.Equal("type new.md", line.Text); + Assert.False(line.HasSelection); + } + + [Fact] + public void BackspaceAndDeleteRemoveTheSelection() + { + (CommandLine line, RecordingContext ctx, _) = New("echo hello"); + Hidden(line, ctx, ConsoleKey.Home, KeyMods.Shift); + + Assert.True(line.HandleKey(Keys.Key(ConsoleKey.Backspace), ctx)); + Assert.Equal(string.Empty, line.Text); + + Keys.Type(line, ctx, "echo hello"); + line.HandleKey(Keys.Key(ConsoleKey.Home), ctx); + for (int i = 0; i < 5; i++) + { + Hidden(line, ctx, ConsoleKey.RightArrow, KeyMods.Shift); + } + + Assert.True(line.HandleKey(Keys.Key(ConsoleKey.Delete), ctx)); + Assert.Equal("hello", line.Text); + Assert.Equal(0, line.Caret); + } + + [Fact] + public void CtrlCAndCtrlInsCopyAndShiftDelCuts() + { + (CommandLine line, RecordingContext ctx, MemoryClipboard clipboard) = New("ping example.org"); + Hidden(line, ctx, ConsoleKey.LeftArrow, KeyMods.Ctrl | KeyMods.Shift); + Assert.Equal("org", line.SelectedText); + + Assert.True(line.HandleKey(Keys.Key(ConsoleKey.C, KeyMods.Ctrl), ctx)); + Assert.Equal("org", clipboard.GetText()); + Assert.Equal("ping example.org", line.Text); + + clipboard.SetText(string.Empty); + Assert.True(line.HandleKey(Keys.Key(ConsoleKey.Insert, KeyMods.Ctrl), ctx)); + Assert.Equal("org", clipboard.GetText()); + + clipboard.SetText(string.Empty); + Assert.True(line.HandleKey(Keys.Key(ConsoleKey.Delete, KeyMods.Shift), ctx)); + Assert.Equal("org", clipboard.GetText()); + Assert.Equal("ping example.", line.Text); + } + + [Fact] + public void CtrlASelectsTheWholeLineWhileThePanelsAreHidden() + { + (CommandLine line, RecordingContext ctx, _) = New("cls"); + + Assert.True(Hidden(line, ctx, ConsoleKey.A, KeyMods.Ctrl)); + Assert.Equal("cls", line.SelectedText); + } + + [Fact] + public void AnUnshiftedMoveDropsTheSelection() + { + (CommandLine line, RecordingContext ctx, _) = New("abc"); + Hidden(line, ctx, ConsoleKey.LeftArrow, KeyMods.Shift); + Assert.True(line.HasSelection); + + line.HandleKey(Keys.Key(ConsoleKey.LeftArrow), ctx); + Assert.False(line.HasSelection); + } + + [Fact] + public void TheSelectionIsDrawnInTheSelectedColour() + { + (CommandLine line, RecordingContext ctx, _) = New("abcd"); + Hidden(line, ctx, ConsoleKey.LeftArrow, KeyMods.Shift); + Hidden(line, ctx, ConsoleKey.LeftArrow, KeyMods.Shift); + + var buffer = new ScreenBuffer(40, 1); + line.Draw(buffer, 0, "C:\\"); + + int textStart = "C:\\>".Length; + Assert.NotEqual(ctx.Theme.CommandLineSelected, buffer.Get(textStart + 1, 0).Style); + Assert.Equal(ctx.Theme.CommandLineSelected, buffer.Get(textStart + 2, 0).Style); + Assert.Equal(ctx.Theme.CommandLineSelected, buffer.Get(textStart + 3, 0).Style); + } +} + public class CommandLinePasteTests { private static (CommandLine Line, RecordingContext Ctx, MemoryClipboard Clipboard) New() diff --git a/tests/OpenStus.Tests/FileEntryTests.cs b/tests/OpenStus.Tests/FileEntryTests.cs index eb5a88c..1c62ac9 100644 --- a/tests/OpenStus.Tests/FileEntryTests.cs +++ b/tests/OpenStus.Tests/FileEntryTests.cs @@ -238,10 +238,60 @@ public void TheAdvertisedExtensionListsMatchTheContract() FileTypeClassifier.ExecutableExtensions.Order(StringComparer.Ordinal)); Assert.Equal( - ["7z", "bz2", "cab", "gz", "iso", "rar", "tar", "xz", "zip"], + ["7z", "apk", "arj", "bz2", "cab", "deb", "ear", "gz", "iso", "jar", "lz", "lzh", "lzma", + "nupkg", "rar", "rpm", "tar", "tbz2", "tgz", "txz", "vsix", "war", "whl", "wim", "xz", "z", + "zip", "zst"], FileTypeClassifier.ArchiveExtensions.Order(StringComparer.Ordinal)); } + [Theory] + [InlineData("HFConnect-2026-05-09-clean.bak")] + [InlineData("HFDB-DEV-CLEAN.BAK")] + [InlineData("setup.tmp")] + [InlineData("notes.txt~")] + [InlineData("~$report.docx")] + [InlineData("install.$$$")] + [InlineData(".main.swp")] + [InlineData("patch.orig")] + public void BackupAndTemporaryNamesAreRecognised(string name) + { + var entry = new FileEntry { Name = name }; + + Assert.True(entry.IsTemporary); + Assert.Equal(FileCategory.Temporary, FileTypeClassifier.Classify(entry)); + } + + [Theory] + [InlineData("HFDB-DEV-CLEAN.mdf")] + [InlineData("readme.txt")] + [InlineData("backup")] + [InlineData("tmp")] + public void OrdinaryNamesAreNotTemporary(string name) => + Assert.False(new FileEntry { Name = name }.IsTemporary); + + [Theory] + [InlineData("photo.JPG")] + [InlineData("logo.png")] + [InlineData("song.mp3")] + [InlineData("clip.mkv")] + public void MediaNamesAreRecognised(string name) + { + var entry = new FileEntry { Name = name }; + + Assert.True(entry.IsMedia); + Assert.Equal(FileCategory.Media, FileTypeClassifier.Classify(entry)); + } + + [Fact] + public void AFolderIsNeverTemporaryOrMedia() + { + var folder = new FileEntry { Name = "old.bak", IsDirectory = true, Attributes = FileAttributes.Directory }; + var images = new FileEntry { Name = "shots.png", IsDirectory = true, Attributes = FileAttributes.Directory }; + + Assert.False(folder.IsTemporary); + Assert.False(images.IsMedia); + } + [Fact] public void ClassifyFollowsThePanelsColourOrder() { diff --git a/tests/OpenStus.Tests/PanelDrawTests.cs b/tests/OpenStus.Tests/PanelDrawTests.cs index 29fc203..0f23c0a 100644 --- a/tests/OpenStus.Tests/PanelDrawTests.cs +++ b/tests/OpenStus.Tests/PanelDrawTests.cs @@ -1048,6 +1048,31 @@ public void HiddenOutranksDirectoryWhenColouringAnEntry() Assert.Equal(theme.PanelSelectedFile, panel.StyleFor(hiddenDir, onCursor: false)); } + /// + /// Backups and temporaries take their own brown, media its own light red, and both still yield + /// to hidden, to the cursor and to a tag. + /// + [Fact] + public void BackupsTemporariesAndMediaHaveTheirOwnColours() + { + var panel = PanelFixture.Panel(); + Theme theme = panel.Theme; + + FileEntry backup = new() { Name = "HFDB-DEV-CLEAN.bak", FullPath = Path.Combine(PanelFixture.DemoPath, "HFDB-DEV-CLEAN.bak") }; + FileEntry database = new() { Name = "HFDB-DEV-CLEAN.mdf", FullPath = Path.Combine(PanelFixture.DemoPath, "HFDB-DEV-CLEAN.mdf") }; + FileEntry picture = new() { Name = "logo.png", FullPath = Path.Combine(PanelFixture.DemoPath, "logo.png") }; + FileEntry hiddenTemp = new() { Name = "~$draft.docx", Attributes = FileAttributes.Hidden }; + + Assert.Equal(theme.PanelTemporary, panel.StyleFor(backup, onCursor: false)); + Assert.Equal(theme.PanelText, panel.StyleFor(database, onCursor: false)); + Assert.Equal(theme.PanelMedia, panel.StyleFor(picture, onCursor: false)); + Assert.Equal(theme.PanelHidden, panel.StyleFor(hiddenTemp, onCursor: false)); + + Assert.Equal(theme.PanelCursor, panel.StyleFor(backup, onCursor: true)); + backup.Selected = true; + Assert.Equal(theme.PanelSelectedFile, panel.StyleFor(backup, onCursor: false)); + } + [Fact] public void SelectedOrCurrentFallsBackToTheCursorButNeverToTheParentEntry() { diff --git a/tests/OpenStus.Tests/RenderingTests.cs b/tests/OpenStus.Tests/RenderingTests.cs index c191342..212648f 100644 --- a/tests/OpenStus.Tests/RenderingTests.cs +++ b/tests/OpenStus.Tests/RenderingTests.cs @@ -773,6 +773,25 @@ public void AVisibleCursorIsPlacedAndShownAtTheEndOfTheFrame() Assert.EndsWith("\u001b[2;3H\u001b[?25h" + End, frame, StringComparison.Ordinal); } + /// + /// The editor asks for a blinking bar. The shape is written once, when it changes, and handed + /// back to the user's own shape as soon as the default is asked for again. + /// + [Fact] + public void ACursorShapeIsWrittenOnlyWhenItChanges() + { + using var t = Terminal.Create(6, 2); + + t.SetCursor(1, 0, visible: true, CursorShape.BlinkingBar); + Assert.EndsWith("\u001b[1;2H\u001b[5 q\u001b[?25h" + End, t.BuildFrameText(), StringComparison.Ordinal); + + t.SetCursor(2, 0, visible: true, CursorShape.BlinkingBar); + Assert.DoesNotContain(" q", t.BuildFrameText(), StringComparison.Ordinal); + + t.SetCursor(2, 0, visible: true); + Assert.Contains("\u001b[0 q", t.BuildFrameText(), StringComparison.Ordinal); + } + [Fact] public void AnOutOfRangeCursorIsClampedIntoTheBuffer() { diff --git a/tests/OpenStus.Tests/SyntaxTests.cs b/tests/OpenStus.Tests/SyntaxTests.cs index 6d51b38..5e8d306 100644 --- a/tests/OpenStus.Tests/SyntaxTests.cs +++ b/tests/OpenStus.Tests/SyntaxTests.cs @@ -435,6 +435,58 @@ public void TheRegistryKnowsTheAdvertisedExtensionsAndIgnoresTheRest() Assert.Null(SyntaxRegistry.ForPath("noextension")); Assert.Null(SyntaxRegistry.ForPath(null)); Assert.Null(SyntaxRegistry.ForPath(string.Empty)); + + // The editor's fallback: a type nothing claims is still read as plain text. + Assert.Same(SyntaxRegistry.PlainText, SyntaxRegistry.ForPathOrPlainText("readme.txt")); + Assert.Same(SyntaxRegistry.PlainText, SyntaxRegistry.ForPathOrPlainText("noextension")); + Assert.NotSame(SyntaxRegistry.PlainText, SyntaxRegistry.ForPathOrPlainText("a.cs")); + } + + [Fact] + public void PlainTextColoursNumbersThatStandOnTheirOwn() + { + const string Line = "2026-05-09 12:30:45 retry 3 of 5 from 192.168.0.10 build v2 x86 5px 0x1F."; + List tokens = Tokens(Line, SyntaxRegistry.PlainText); + List numbers = [.. tokens.Where(t => t.Kind == TokenKind.Number).Select(t => Slice(Line, t))]; + + Assert.Equal(["2026-05-09", "12:30:45", "3", "5", "192.168.0.10", "0x1F"], numbers); + } + + [Fact] + public void PlainTextColoursClosedStringsUrlsAndWellKnownWords() + { + const string Line = "ERROR: \"disk full\" at https://example.com/x, don't retry; value=true"; + List tokens = Tokens(Line, SyntaxRegistry.PlainText); + + Assert.Contains(tokens, t => t.Kind == TokenKind.Keyword && Slice(Line, t) == "ERROR"); + Assert.Contains(tokens, t => t.Kind == TokenKind.String && Slice(Line, t) == "\"disk full\""); + Assert.Contains(tokens, t => t.Kind == TokenKind.String && Slice(Line, t) == "https://example.com/x"); + Assert.Contains(tokens, t => t.Kind == TokenKind.Keyword && Slice(Line, t) == "true"); + + // The apostrophe in "don't" opens nothing, and nothing is left open for the next line. + Assert.Equal(2, tokens.Count(t => t.Kind != TokenKind.Keyword)); + Assert.Equal(SyntaxMode.None, SyntaxTokenizer.ScanLine(Line, SyntaxRegistry.PlainText, default).Mode); + } + + [Theory] + [InlineData("# a comment line")] + [InlineData(" ; an ini comment")] + [InlineData("// a slash comment")] + public void PlainTextCommentLinesAreComments(string line) + { + TokenSpan span = Assert.Single(Tokens(line, SyntaxRegistry.PlainText)); + Assert.Equal(TokenKind.Comment, span.Kind); + Assert.Equal(line.TrimStart(), Slice(line, span)); + } + + [Fact] + public void PlainTextSectionHeadersAreKeywords() + { + TokenSpan span = Assert.Single(Tokens("[Settings]", SyntaxRegistry.PlainText)); + Assert.Equal(TokenKind.Keyword, span.Kind); + + // A bracket that is not the whole line is just text. + Assert.DoesNotContain(Tokens("[x] done", SyntaxRegistry.PlainText), t => t.Kind == TokenKind.Keyword); } [Fact] @@ -521,28 +573,80 @@ public void TheEditorColoursKeywordsStringsAndComments() var screen = new ScreenBuffer(60, 10); editor.Draw(screen); - // Cell (0,0) carries the inverted caret, so the spans are asserted next to it. - Assert.Equal(theme.SyntaxKeyword, screen.Get(1, 0).Style); // 'return' + Assert.Equal(theme.SyntaxKeyword, screen.Get(0, 0).Style); // 'return', under the caret too + Assert.Equal(theme.SyntaxKeyword, screen.Get(1, 0).Style); Assert.Equal(theme.SyntaxString, screen.Get(8, 0).Style); // '"text"' Assert.Equal(theme.SyntaxComment, screen.Get(16, 0).Style); // '// note' Assert.Equal(theme.EditorText, screen.Get(6, 0).Style); // the space between } [Fact] - public void TheEditorDrawsPlainWhenHighlightingIsOffOrTheTypeIsUnknown() + public void TheEditorDrawsPlainWhenHighlightingIsOff() { var theme = Theme.Classic(); var screen = new ScreenBuffer(60, 10); - // Column 1: cell (0,0) carries the inverted caret. FileEditor off = Editor(theme, @"C:\demo\sample.cs", "return 1;"); off.SyntaxHighlight = false; off.Draw(screen); Assert.Equal(theme.EditorText, screen.Get(1, 0).Style); + Assert.Equal(theme.EditorText, screen.Get(7, 0).Style); + } + [Fact] + public void TheEditorColoursNumbersInAFileOfUnknownType() + { + var theme = Theme.Classic(); + var screen = new ScreenBuffer(60, 10); + + // No language claims .txt: its words stay plain, its numbers do not. FileEditor unknown = Editor(theme, @"C:\demo\notes.txt", "return 1;"); unknown.Draw(screen); Assert.Equal(theme.EditorText, screen.Get(1, 0).Style); + Assert.Equal(theme.SyntaxNumber, screen.Get(7, 0).Style); + } + + [Fact] + public void F5SwitchesTheColouringAndTheKeyBarSaysWhichWay() + { + var theme = Theme.Classic(); + FileEditor editor = Editor(theme, @"C:\demo\server.log", "retry 3 of 5"); + + Assert.Equal("Plain", editor.KeyBarFor(KeyMods.None)![4]); + + Assert.True(editor.HandleInput(InputEvent.FromKey(new KeyEvent(ConsoleKey.F5, '\0', KeyMods.None)))); + Assert.False(editor.SyntaxHighlight); + Assert.Equal("Colour", editor.KeyBarFor(KeyMods.None)![4]); + + var screen = new ScreenBuffer(60, 10); + editor.Draw(screen); + Assert.Equal(theme.EditorText, screen.Get(6, 0).Style); // '3', plain now + + editor.HandleInput(InputEvent.FromKey(new KeyEvent(ConsoleKey.F5, '\0', KeyMods.None))); + editor.Draw(screen); + Assert.Equal(theme.SyntaxNumber, screen.Get(6, 0).Style); + } + + /// + /// The caret is the terminal's own blinking cursor - a bar while inserting, a block while + /// overwriting - and nothing is painted into the cell under it. + /// + [Fact] + public void TheCaretIsTheTerminalCursorAndLeavesTheCellAlone() + { + var theme = Theme.Classic(); + FileEditor editor = Editor(theme, @"C:\demo\notes.txt", "abc"); + + var screen = new ScreenBuffer(60, 10); + editor.Draw(screen); + + Assert.Equal(theme.EditorText, screen.Get(0, 0).Style); + Assert.Equal((0, 0), (editor.CursorScreenX, editor.CursorScreenY)); + Assert.True(editor.CursorVisible); + Assert.Equal(CursorShape.BlinkingBar, editor.CursorShape); + + editor.HandleInput(InputEvent.FromKey(new KeyEvent(ConsoleKey.Insert, '\0', KeyMods.None))); + Assert.Equal(CursorShape.BlinkingBlock, editor.CursorShape); } [Fact] diff --git a/tests/OpenStus.Tests/ThemeTests.cs b/tests/OpenStus.Tests/ThemeTests.cs index 79cfc24..fca2690 100644 --- a/tests/OpenStus.Tests/ThemeTests.cs +++ b/tests/OpenStus.Tests/ThemeTests.cs @@ -23,6 +23,8 @@ public void ClassicMatchesThePanelPalette() Assert.Equal(Style(ConsoleColor.DarkCyan, ConsoleColor.DarkBlue), t.PanelHidden); // hidden or system entries Assert.Equal(Style(ConsoleColor.Green, ConsoleColor.DarkBlue), t.PanelExecutable); // executables (*.exe group) Assert.Equal(Style(ConsoleColor.Magenta, ConsoleColor.DarkBlue), t.PanelArchive); + Assert.Equal(Style(ConsoleColor.DarkYellow, ConsoleColor.DarkBlue), t.PanelTemporary); // *.bak, *.tmp + Assert.Equal(Style(ConsoleColor.Red, ConsoleColor.DarkBlue), t.PanelMedia); // images, sound, video Assert.Equal(Style(ConsoleColor.Yellow, ConsoleColor.DarkBlue), t.PanelSelectedFile); Assert.Equal(Style(ConsoleColor.Black, ConsoleColor.DarkCyan), t.PanelCursor); Assert.Equal(Style(ConsoleColor.Yellow, ConsoleColor.DarkCyan), t.PanelCursorSelected); @@ -195,8 +197,8 @@ public void TheKeyBarNumberAndBackgroundShareOneAttribute() [Fact] public void EveryContractMemberIsAddressableAndTheTableIsComplete() { - Assert.Equal(85, Theme.Slots.Count); - Assert.Equal(85, Theme.Slots.Select(s => s.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.Equal(87, Theme.Slots.Count); + Assert.Equal(87, Theme.Slots.Select(s => s.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count()); var t = Theme.Classic(); foreach (var slot in Theme.Slots)