diff --git a/examples/ted/InlineProgress.cs b/examples/ted/InlineProgress.cs
new file mode 100644
index 0000000..b2f5326
--- /dev/null
+++ b/examples/ted/InlineProgress.cs
@@ -0,0 +1,20 @@
+namespace Ted;
+
+///
+/// Reports progress synchronously for non-hosted app scenarios where
+/// would queue callbacks to the thread pool instead of an application UI thread.
+///
+internal sealed class InlineProgress : IProgress
+{
+ private readonly Action _handler;
+
+ public InlineProgress (Action handler)
+ {
+ _handler = handler ?? throw new ArgumentNullException (nameof (handler));
+ }
+
+ public void Report (T value)
+ {
+ _handler (value);
+ }
+}
diff --git a/examples/ted/Program.cs b/examples/ted/Program.cs
index f9f12be..434c03b 100644
--- a/examples/ted/Program.cs
+++ b/examples/ted/Program.cs
@@ -23,13 +23,15 @@
if (!string.IsNullOrWhiteSpace (requestedPath))
{
+ // Defer onto the app loop so the window renders first, then the file streams in progressively
+ // instead of blocking the UI until the whole file is read.
if (File.Exists (requestedPath))
{
- ted.SetDocument (File.ReadAllText (requestedPath), requestedPath);
+ app.Invoke (() => ted.BeginOpenFile (requestedPath));
}
else
{
- ted.OpenMissingFile (requestedPath);
+ app.Invoke (() => ted.OpenMissingFile (requestedPath));
}
}
diff --git a/examples/ted/TedApp.FileOperations.cs b/examples/ted/TedApp.FileOperations.cs
index 54ca4e5..65e2600 100644
--- a/examples/ted/TedApp.FileOperations.cs
+++ b/examples/ted/TedApp.FileOperations.cs
@@ -1,3 +1,4 @@
+using System.Text;
using Terminal.Gui.Document;
using Terminal.Gui.Highlighting;
using Terminal.Gui.Resources;
@@ -7,6 +8,17 @@ namespace Ted;
public sealed partial class TedApp
{
+ /// Minimum byte/character delta between queued streaming status updates.
+ private const long StreamingStatusInterval = 256 * 1024;
+
+ /// Minimum elapsed milliseconds between queued streaming status updates.
+ private const int StreamingStatusMilliseconds = 100;
+
+ private readonly Lock _streamingStatusLock = new ();
+ private long _lastStreamingStatusUnits;
+ private DateTime _lastStreamingStatusUpdate = DateTime.MinValue;
+ private long _streamingStatusOperationId;
+
/// The path currently associated with , or for an untitled buffer.
public string? CurrentFilePath { get; private set; }
@@ -19,14 +31,77 @@ public sealed partial class TedApp
/// Dialog hook used by . Tests can replace it to avoid interactive UI.
public Func ShowSaveChangesDialog { get; set; }
- /// File read hook used by . Tests can replace it with an in-memory fake.
- public Func ReadAllText { get; set; } = File.ReadAllText;
+ private bool _openReadWasSet;
+
+ private Func _readAllText = File.ReadAllText;
///
- /// File write hook used by and . Tests can replace it with an
- /// in-memory fake.
+ /// File read hook retained for source compatibility. Replacing this hook adapts opens by buffering the
+ /// returned text as UTF-8; prefer for streaming large files.
///
- public Action WriteAllText { get; set; } = File.WriteAllText;
+ public Func ReadAllText
+ {
+ get => _readAllText;
+ set
+ {
+ _readAllText = value ?? throw new ArgumentNullException (nameof (value));
+
+ if (!_openReadWasSet)
+ {
+ _openRead = OpenReadFromReadAllText;
+ }
+ }
+ }
+
+ private Func _openRead = File.OpenRead;
+
+ /// File stream hook used by . Tests can replace it with an in-memory fake.
+ public Func OpenRead
+ {
+ get => _openRead;
+ set
+ {
+ _openRead = value ?? throw new ArgumentNullException (nameof (value));
+ _openReadWasSet = true;
+ }
+ }
+
+ private bool _createWriteWasSet;
+
+ private Action _writeAllText = File.WriteAllText;
+
+ ///
+ /// File write hook retained for source compatibility. Streaming saves use .
+ ///
+ public Action WriteAllText
+ {
+ get => _writeAllText;
+ set
+ {
+ _writeAllText = value ?? throw new ArgumentNullException (nameof (value));
+
+ if (!_createWriteWasSet)
+ {
+ _createWrite = CreateWriteFromWriteAllText;
+ }
+ }
+ }
+
+ private Func _createWrite = path => File.Create (path);
+
+ /// File stream hook used by and .
+ public Func CreateWrite
+ {
+ get => _createWrite;
+ set
+ {
+ _createWrite = value ?? throw new ArgumentNullException (nameof (value));
+ _createWriteWasSet = true;
+ }
+ }
+
+ /// The currently running background load, if any.
+ public Task? CurrentLoadTask { get; private set; }
/// Gets whether the current editor document has unsaved changes.
public bool IsDocumentModified => Editor.Document?.UndoStack.IsOriginalFile == false;
@@ -42,14 +117,26 @@ public bool OpenFile ()
{
var filePath = ShowOpenDialog ();
+ return !string.IsNullOrWhiteSpace (filePath) && OpenFileAsync (filePath).GetAwaiter ().GetResult ();
+ }
+
+ /// Prompts for a file path, then asynchronously streams that file into the editor.
+ public async Task OpenFileAsync (CancellationToken cancellationToken = default)
+ {
+ var filePath = ShowOpenDialog ();
+
if (string.IsNullOrWhiteSpace (filePath))
{
return false;
}
- SetDocument (ReadAllText (filePath), filePath);
+ return await OpenFileAsync (filePath, false, cancellationToken);
+ }
- return true;
+ /// Asynchronously streams the specified file into the editor.
+ public Task OpenFileAsync (string filePath, CancellationToken cancellationToken = default)
+ {
+ return OpenFileAsync (filePath, false, cancellationToken);
}
/// Opens a CLI-requested missing file path as an empty, modified document bound to that path.
@@ -69,14 +156,35 @@ public void OpenMissingFile (string filePath)
/// Saves the editor text to the current file, or prompts for a path if the buffer is untitled.
public bool SaveFile ()
+ {
+ return CurrentFilePath is null ? SaveFileAs () : SaveFileAsync ().GetAwaiter ().GetResult ();
+ }
+
+ /// Asynchronously streams the editor text to the current file, or prompts for a path if untitled.
+ public Task SaveFileAsync (CancellationToken cancellationToken = default)
+ {
+ return SaveFileAsync (false, cancellationToken);
+ }
+
+ private async Task SaveFileAsync (bool marshalToApp, CancellationToken cancellationToken = default)
{
if (CurrentFilePath is null)
{
- return SaveFileAs ();
+ return await SaveFileAsAsync (marshalToApp, cancellationToken);
}
- WriteAllText (CurrentFilePath, GetEditorText ());
- Editor.Document!.UndoStack.MarkAsOriginalFile ();
+ try
+ {
+ await SaveFileToAsync (CurrentFilePath, marshalToApp, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return false;
+ }
+ catch (Exception ex) when (IsFileOperationException (ex))
+ {
+ return false;
+ }
return true;
}
@@ -86,18 +194,25 @@ public bool SaveFileAs ()
{
var filePath = ShowSaveDialog ();
+ return !string.IsNullOrWhiteSpace (filePath) && SaveFileAsAsync (filePath).GetAwaiter ().GetResult ();
+ }
+
+ /// Prompts for a file path, then asynchronously streams the editor text to that path.
+ public Task SaveFileAsAsync (CancellationToken cancellationToken = default)
+ {
+ return SaveFileAsAsync (false, cancellationToken);
+ }
+
+ private async Task SaveFileAsAsync (bool marshalToApp, CancellationToken cancellationToken = default)
+ {
+ var filePath = ShowSaveDialog ();
+
if (string.IsNullOrWhiteSpace (filePath))
{
return false;
}
- CurrentFilePath = filePath;
- WriteAllText (filePath, GetEditorText ());
- Editor.Document!.UndoStack.MarkAsOriginalFile ();
- UpdateFileNameShortcut ();
- UpdatePreviewVisibility ();
-
- return true;
+ return await SaveFileAsAsync (filePath, marshalToApp, cancellationToken);
}
/// Quits ted, prompting to save first when the current document has unsaved changes.
@@ -120,26 +235,7 @@ internal void SetDocument (string text, string? filePath)
Editor.CaretOffset = 0;
CurrentFilePath = filePath;
- // Auto-detect highlighting from file extension.
- IHighlightingDefinition? def = null;
-
- if (filePath is not null)
- {
- var ext = Path.GetExtension (filePath);
-
- if (!string.IsNullOrEmpty (ext))
- {
- def = HighlightingManager.Instance.GetDefinitionByExtension (ext);
- }
- }
-
- Editor.HighlightingDefinition = def;
- LanguageShortcut.Title = def?.Name ?? "Plain Text";
-
- UpdateFileNameShortcut ();
- UpdatePreviewVisibility ();
- InstallFolding ();
- Editor.SetNeedsDraw ();
+ ApplyFileMetadata (filePath);
}
private string? ShowDefaultOpenDialog ()
@@ -198,13 +294,6 @@ private SaveChangesChoice ShowDefaultSaveChangesDialog ()
};
}
- private string GetEditorText ()
- {
- return Editor.Document is null
- ? throw new InvalidOperationException ("ted cannot save because the editor has no document.")
- : Editor.Document.Text;
- }
-
private void New ()
{
if (!ConfirmSaveChanges ())
@@ -222,12 +311,25 @@ private void Open ()
return;
}
- OpenFile ();
+ var filePath = ShowOpenDialog ();
+
+ if (string.IsNullOrWhiteSpace (filePath))
+ {
+ return;
+ }
+
+ CurrentLoadTask = OpenFileAsync (filePath, true);
}
- private void Save () { SaveFile (); }
+ private void Save ()
+ {
+ _ = SaveFileAsync (true);
+ }
- private void SaveAs () { SaveFileAs (); }
+ private void SaveAs ()
+ {
+ _ = SaveFileAsAsync (true);
+ }
private void Quit ()
{
@@ -248,4 +350,490 @@ private bool ConfirmSaveChanges ()
_ => false
};
}
+
+ private async Task OpenFileAsync (
+ string filePath,
+ bool marshalToApp,
+ CancellationToken cancellationToken = default)
+ {
+ long? statusOperationId = null;
+
+ try
+ {
+ await using Stream stream = OpenRead (filePath);
+ var fileSize = GetStreamLength (stream);
+ var startedStatusOperationId = BeginStreamingStatus (FormatStartingProgress ("Loading", fileSize));
+ statusOperationId = startedStatusOperationId;
+
+ IProgress progress =
+ CreateStreamingProgress (progress => ReportLoadProgress (startedStatusOperationId, progress));
+
+ // When there is a running app loop, hand Editor.LoadAsync a UI-thread marshal so it can read on a
+ // background thread and append each chunk on the UI thread — the editor paints an empty buffer
+ // immediately and fills in progressively instead of blocking until the whole file is read.
+ Func? marshal = marshalToApp ? InvokeOnAppAsync : null;
+
+ await Editor.LoadAsync (
+ stream,
+ encoding: null,
+ progress: progress,
+ cancellationToken: cancellationToken,
+ marshal: marshal);
+
+ await RunOnApp (
+ marshalToApp,
+ () =>
+ {
+ CurrentFilePath = filePath;
+ ApplyFileMetadata (filePath);
+ CompleteStreamingStatus (
+ startedStatusOperationId,
+ FormatCompletedProgress ("Loaded", fileSize));
+ });
+
+ // Non-marshalled (sync / test) path: post-load work above ran on a background continuation thread and
+ // re-claimed TextDocument ownership. Release it as the final step so the caller's thread can use the
+ // document. The marshalled path keeps UI-thread ownership and must not do this.
+ if (!marshalToApp)
+ {
+ Editor.Document?.SetOwnerThread (null);
+ }
+
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ if (statusOperationId is { } startedStatusOperationId)
+ {
+ CompleteStreamingStatus (startedStatusOperationId, "Load canceled");
+ }
+ else
+ {
+ CompleteStreamingStatus ("Load canceled");
+ }
+
+ return false;
+ }
+ catch (Exception ex) when (IsFileOperationException (ex))
+ {
+ if (statusOperationId is { } startedStatusOperationId)
+ {
+ CompleteStreamingStatus (startedStatusOperationId, "Load failed");
+ }
+ else
+ {
+ CompleteStreamingStatus ("Load failed");
+ }
+
+ return false;
+ }
+ }
+
+ ///
+ /// Begins a progressive, UI-marshalled load of against the running app loop.
+ /// Used by the CLI path so the window appears before the file finishes loading.
+ ///
+ public void BeginOpenFile (string filePath)
+ {
+ CurrentLoadTask = OpenFileAsync (filePath, true);
+ }
+
+ private Task RunOnApp (bool marshalToApp, Action action)
+ {
+ if (marshalToApp)
+ {
+ return InvokeOnAppAsync (action);
+ }
+
+ action ();
+
+ return Task.CompletedTask;
+ }
+
+ private async Task SaveFileAsAsync (
+ string filePath,
+ bool marshalToApp = false,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await SaveFileToAsync (filePath, marshalToApp, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return false;
+ }
+ catch (Exception ex) when (IsFileOperationException (ex))
+ {
+ return false;
+ }
+
+ CurrentFilePath = filePath;
+ UpdateFileNameShortcut ();
+ UpdatePreviewVisibility ();
+
+ return true;
+ }
+
+ private async Task SaveFileToAsync (string filePath, bool marshalToApp, CancellationToken cancellationToken)
+ {
+ long? statusOperationId = null;
+
+ try
+ {
+ await using Stream stream = CreateWrite (filePath);
+ var startedStatusOperationId = BeginStreamingStatus (FormatStartingProgress ("Saving", null));
+ statusOperationId = startedStatusOperationId;
+
+ IProgress progress =
+ CreateStreamingProgress (progress => ReportSaveProgress (startedStatusOperationId, progress));
+ await Editor.SaveAsync (stream, progress, cancellationToken);
+ var fileSize = GetStreamLength (stream);
+
+ void MarkSaved ()
+ {
+ Editor.Document!.UndoStack.MarkAsOriginalFile ();
+ CompleteStreamingStatus (startedStatusOperationId, FormatCompletedProgress ("Saved", fileSize));
+ }
+
+ if (marshalToApp)
+ {
+ await InvokeOnAppAsync (MarkSaved);
+ }
+ else
+ {
+ MarkSaved ();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ if (statusOperationId is { } startedStatusOperationId)
+ {
+ CompleteStreamingStatus (startedStatusOperationId, "Save canceled");
+ }
+ else
+ {
+ CompleteStreamingStatus ("Save canceled");
+ }
+
+ throw;
+ }
+ catch (Exception ex) when (IsFileOperationException (ex))
+ {
+ if (statusOperationId is { } startedStatusOperationId)
+ {
+ CompleteStreamingStatus (startedStatusOperationId, "Save failed");
+ }
+ else
+ {
+ CompleteStreamingStatus ("Save failed");
+ }
+
+ throw;
+ }
+ }
+
+ private Stream OpenReadFromReadAllText (string path)
+ {
+ return new MemoryStream (Encoding.UTF8.GetBytes (_readAllText (path)));
+ }
+
+ private Stream CreateWriteFromWriteAllText (string path)
+ {
+ return new WriteAllTextStream (path, _writeAllText);
+ }
+
+ private static bool IsFileOperationException (Exception ex)
+ {
+ return ex is IOException or UnauthorizedAccessException;
+ }
+
+ private void ApplyFileMetadata (string? filePath)
+ {
+ IHighlightingDefinition? def = null;
+
+ if (filePath is not null)
+ {
+ var ext = Path.GetExtension (filePath);
+
+ if (!string.IsNullOrEmpty (ext))
+ {
+ def = HighlightingManager.Instance.GetDefinitionByExtension (ext);
+ }
+ }
+
+ Editor.HighlightingDefinition = def;
+ LanguageShortcut.Title = def?.Name ?? "Plain Text";
+
+ UpdateFileNameShortcut ();
+ UpdatePreviewVisibility ();
+ InstallFolding ();
+ Editor.SetNeedsDraw ();
+ }
+
+ private void ReportLoadProgress (long statusOperationId, TextDocumentProgress progress)
+ {
+ if (!ShouldReportStreamingProgress (statusOperationId, progress))
+ {
+ return;
+ }
+
+ SetLoadStatus (FormatProgress ("Loading", progress), true, statusOperationId);
+ }
+
+ private void ReportSaveProgress (long statusOperationId, TextDocumentProgress progress)
+ {
+ if (!ShouldReportStreamingProgress (statusOperationId, progress))
+ {
+ return;
+ }
+
+ SetLoadStatus (FormatProgress ("Saving", progress), true, statusOperationId);
+ }
+
+ private IProgress CreateStreamingProgress (Action handler)
+ {
+ return App is null
+ ? new InlineProgress (handler)
+ : new Progress (handler);
+ }
+
+ private long BeginStreamingStatus (string status)
+ {
+ var statusOperationId = Interlocked.Increment (ref _streamingStatusOperationId);
+
+ ResetStreamingStatusThrottle ();
+ SetLoadStatus (status, true, statusOperationId);
+
+ return statusOperationId;
+ }
+
+ private void CompleteStreamingStatus (long statusOperationId, string status)
+ {
+ var completionOperationId = statusOperationId + 1;
+
+ // A newer operation owns the status item; stale completions must not overwrite it.
+ if (Interlocked.CompareExchange (
+ ref _streamingStatusOperationId,
+ completionOperationId,
+ statusOperationId)
+ != statusOperationId)
+ {
+ return;
+ }
+
+ SetLoadStatus (status, false, completionOperationId);
+ }
+
+ private void CompleteStreamingStatus (string status)
+ {
+ var completionOperationId = Interlocked.Increment (ref _streamingStatusOperationId);
+ SetLoadStatus (status, false, completionOperationId);
+ }
+
+ private void SetLoadStatus (string status, bool showSpinner, long statusOperationId)
+ {
+ void Update ()
+ {
+ if (Interlocked.Read (ref _streamingStatusOperationId) != statusOperationId)
+ {
+ return;
+ }
+
+ // The status spinner is visible only while it is actively spinning.
+ LoadStatusSpinner.Visible = showSpinner;
+ LoadStatusSpinner.AutoSpin = showSpinner;
+ LoadSpinnerShortcut.Title = status;
+ LoadSpinnerShortcut.HelpText = status;
+ LoadStatusSpinner.SetNeedsDraw ();
+ LoadSpinnerShortcut.SetNeedsDraw ();
+ }
+
+ if (App is null)
+ {
+ Update ();
+
+ return;
+ }
+
+ App.Invoke (Update);
+ }
+
+ private void ResetStreamingStatusThrottle ()
+ {
+ lock (_streamingStatusLock)
+ {
+ _lastStreamingStatusUpdate = DateTime.MinValue;
+ _lastStreamingStatusUnits = 0;
+ }
+ }
+
+ private bool ShouldReportStreamingProgress (long statusOperationId, TextDocumentProgress progress)
+ {
+ if (Interlocked.Read (ref _streamingStatusOperationId) != statusOperationId)
+ {
+ return false;
+ }
+
+ var processedUnits = progress.BytesProcessed ?? progress.CharactersProcessed;
+ var totalUnits = progress.TotalBytes ?? progress.TotalCharacters;
+
+ if (totalUnits == processedUnits)
+ {
+ return true;
+ }
+
+ lock (_streamingStatusLock)
+ {
+ DateTime now = DateTime.UtcNow;
+
+ if (processedUnits - _lastStreamingStatusUnits < StreamingStatusInterval
+ && now - _lastStreamingStatusUpdate < TimeSpan.FromMilliseconds (StreamingStatusMilliseconds))
+ {
+ return false;
+ }
+
+ _lastStreamingStatusUnits = processedUnits;
+ _lastStreamingStatusUpdate = now;
+ }
+
+ return true;
+ }
+
+ private Task InvokeOnAppAsync (Action action)
+ {
+ if (App is null)
+ {
+ action ();
+
+ return Task.CompletedTask;
+ }
+
+ TaskCompletionSource completion = new ();
+ App.Invoke (() =>
+ {
+ try
+ {
+ action ();
+ completion.SetResult ();
+ }
+ catch (Exception ex)
+ {
+ completion.SetException (ex);
+ }
+ });
+
+ return completion.Task;
+ }
+
+ private static string FormatProgress (string verb, TextDocumentProgress progress)
+ {
+ var processed = progress.BytesProcessed is { } bytesProcessed
+ ? FormatByteCount (bytesProcessed)
+ : $"{progress.CharactersProcessed:N0} chars";
+
+ var total = progress.TotalBytes is { } totalBytes
+ ? FormatByteCount (totalBytes)
+ : progress.TotalCharacters is { } totalCharacters
+ ? $"{totalCharacters:N0} chars"
+ : null;
+
+ if (total is null)
+ {
+ return $"{verb} {processed}";
+ }
+
+ if (progress.Fraction is { } fraction)
+ {
+ return $"{verb} {processed} of {total} ({fraction:P0})";
+ }
+
+ return $"{verb} {processed} of {total}";
+ }
+
+ private static string FormatStartingProgress (string verb, long? totalBytes)
+ {
+ return totalBytes is { } bytes
+ ? $"{verb} 0 B of {FormatByteCount (bytes)}"
+ : $"{verb} 0 B";
+ }
+
+ private static string FormatCompletedProgress (string verb, long? totalBytes)
+ {
+ return totalBytes is { } bytes
+ ? $"{verb} {FormatByteCount (bytes)}"
+ : verb;
+ }
+
+ private static long? GetStreamLength (Stream stream)
+ {
+ if (!stream.CanSeek)
+ {
+ return null;
+ }
+
+ return stream.Length;
+ }
+
+ private static string FormatByteCount (long bytes)
+ {
+ string[] units = ["B", "KiB", "MiB", "GiB", "TiB"];
+ double value = bytes;
+ var unitIndex = 0;
+
+ while (value >= 1024 && unitIndex < units.Length - 1)
+ {
+ value /= 1024;
+ unitIndex++;
+ }
+
+ // Whole bytes read cleaner without decimals; larger units need one decimal for useful precision.
+ var format = unitIndex == 0 ? "N0" : "N1";
+
+ return $"{value.ToString (format)} {units[unitIndex]}";
+ }
+
+ ///
+ /// Adapts streamed saves to the legacy hook by buffering bytes in memory and
+ /// writing the final UTF-8 text on disposal.
+ ///
+ private sealed class WriteAllTextStream : MemoryStream
+ {
+ private readonly string _path;
+ private readonly Action _writeAllText;
+ private bool _hasWritten;
+
+ public WriteAllTextStream (string path, Action writeAllText)
+ {
+ _path = path;
+ _writeAllText = writeAllText;
+ }
+
+ protected override void Dispose (bool disposing)
+ {
+ if (disposing)
+ {
+ WriteOnce ();
+ }
+
+ base.Dispose (disposing);
+ }
+
+ public override async ValueTask DisposeAsync ()
+ {
+ WriteOnce ();
+ await base.DisposeAsync ();
+ }
+
+ private void WriteOnce ()
+ {
+ if (_hasWritten)
+ {
+ return;
+ }
+
+ _hasWritten = true;
+ _writeAllText (_path, Encoding.UTF8.GetString (ToArray ()));
+ }
+ }
}
diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs
index a6dd3c5..c1c78af 100644
--- a/examples/ted/TedApp.cs
+++ b/examples/ted/TedApp.cs
@@ -21,13 +21,26 @@ namespace Ted;
///
public sealed partial class TedApp : Window
{
+ private const int MaximumAutomaticFoldingDocumentLength = 1_000_000;
+
private readonly BraceFoldingStrategy _braceFoldingStrategy;
private readonly Shortcut _fileNameShortcut;
private readonly MenuItem _previewMarkdownMenuItem;
+ // Per-instance config path. Defaults to the real ~/.tui location; tests inject a temp path so they
+ // never touch the developer's real config (and stay parallel-safe — no env/static mutation).
+ private readonly string _configPath;
+
/// Initializes a new .
- public TedApp (bool readOnly = false)
+ /// Opens the editor read-only.
+ ///
+ /// Overrides where view settings persist. uses
+ /// (the real ~/.tui/ted.config.json).
+ ///
+ public TedApp (bool readOnly = false, string? configPath = null)
{
+ _configPath = configPath ?? EditorSettings.GetConfigPath ();
+
Title = "ted — Terminal.Gui.Editor demo";
BorderStyle = LineStyle.None;
@@ -134,11 +147,24 @@ public TedApp (bool readOnly = false)
ToggleMarkdownPreview ();
_previewMarkdownMenuItem.Title = ToggleTitle (e.NewValue == CheckState.Checked, "_Preview Markdown");
};
+ LoadStatusSpinner = new SpinnerView
+ {
+ Style = new SpinnerStyle.Aesthetic (),
+ Width = 8,
+ AutoSpin = false,
+ Visible = false
+ };
StatusBar statusBar =
new ([
new Shortcut { Title = "Language", CommandView = LanguageShortcut },
new Shortcut { Title = "Theme", CommandView = ThemeDropDown },
+ LoadSpinnerShortcut = new Shortcut
+ {
+ CommandView = LoadStatusSpinner,
+ Title = string.Empty,
+ MouseHighlightStates = MouseState.None
+ },
OverwriteShortcut = new Shortcut (Key.Empty, "INS", null)
{ MouseHighlightStates = MouseState.None },
LocShortcut = new Shortcut (Key.Empty, FormatLoc (1, 1), null)
@@ -265,6 +291,12 @@ [new MenuItem ("_About", "About ted", ShowAboutDialog)]),
/// The status-bar dropdown that selects .
public DropDownList ThemeDropDown { get; }
+ /// The spinner view shown while streaming file load/save is running.
+ public SpinnerView LoadStatusSpinner { get; }
+
+ /// The status-bar shortcut that hosts .
+ public Shortcut LoadSpinnerShortcut { get; }
+
/// The settings checkbox state for visible tab glyphs.
public CheckBox ShowTabsCheckBox { get; } = new ()
{
@@ -400,7 +432,7 @@ private void SaveViewSettings ()
EditorSettings.IndentSize = Editor.IndentationSize;
EditorSettings.ConvertTabsToSpaces = Editor.ConvertTabsToSpaces;
EditorSettings.AutoIndent = Editor.IndentationStrategy is not null;
- EditorSettings.Save ();
+ EditorSettings.Save (_configPath);
}
private void ShowSettingsDialog ()
@@ -433,6 +465,13 @@ private void InstallFolding ()
return;
}
+ if (Editor.Document.TextLength > MaximumAutomaticFoldingDocumentLength)
+ {
+ Editor.FoldingManager = null;
+
+ return;
+ }
+
FoldingManager fm = new (Editor.Document);
Editor.FoldingManager = fm;
_braceFoldingStrategy.UpdateFoldings (fm, Editor.Document);
@@ -441,9 +480,18 @@ private void InstallFolding ()
private void UpdateFoldings ()
{
- if (Editor.FoldingManager is not null && Editor.Document is not null)
+ if (Editor.FoldingManager is null || Editor.Document is null)
+ {
+ return;
+ }
+
+ if (Editor.Document.TextLength > MaximumAutomaticFoldingDocumentLength)
{
- _braceFoldingStrategy.UpdateFoldings (Editor.FoldingManager, Editor.Document);
+ Editor.FoldingManager = null;
+
+ return;
}
+
+ _braceFoldingStrategy.UpdateFoldings (Editor.FoldingManager, Editor.Document);
}
}
diff --git a/specs/decisions.md b/specs/decisions.md
index 79b601f..894bff6 100644
--- a/specs/decisions.md
+++ b/specs/decisions.md
@@ -36,6 +36,20 @@ Decisions are recorded here when an open question from the plan is resolved. Eac
---
+### DEC-009: Streaming file I/O placement (resolves former OPEN-003)
+
+**Decision**: Streaming `LoadAsync (Stream)` / `SaveAsync (Stream)` lives on `TextDocument`, with `Editor`
+delegating to the document layer for control-level convenience.
+
+**Rationale**: The rope-backed document is the streaming seam: it can append decoded chunks without
+materializing the whole file as one `string`, preserve detected encoding/BOM metadata, and write snapshots
+back out in chunks. `Editor` still exposes `LoadAsync` / `SaveAsync` so control consumers and `ted` do not
+need to know the document construction details.
+
+**Date**: 2026-05-17
+
+---
+
### DEC-003: Tab handling architecture
**Decision**: Tab handling (tab-handling) requires the visual-line pipeline (rendering-pipeline). The codex branch implemented both together. `TabElement` renders tabs through the pipeline, not via inline char-by-char expansion.
@@ -74,14 +88,6 @@ Decisions are recorded here when an open question from the plan is resolved. Eac
---
-### OPEN-003: Async I/O placement
-
-**Question**: `LoadAsync (Stream)` / `SaveAsync` on `Editor` vs. on the document?
-
-**Affected features**: File I/O, large-file performance.
-
----
-
### OPEN-004: Read-only ranges
**Question**: Lift `TextSegmentReadOnlySectionProvider`, or YAGNI?
diff --git a/specs/file-io/spec.md b/specs/file-io/spec.md
new file mode 100644
index 0000000..7eb7e30
--- /dev/null
+++ b/specs/file-io/spec.md
@@ -0,0 +1,48 @@
+# Feature Specification: Streaming File I/O
+
+**Feature Branch**: `copilot/resolve-open-003-large-file-support`
+**Created**: 2026-05-17
+**Status**: Implemented
+**Input**: Issue #150, TextView parity Gap 6, DEC-001, DEC-009
+
+## User Scenarios & Testing
+
+### Streaming document load
+
+As an editor consumer, I can load a multi-megabyte stream into `TextDocument` without first converting the
+entire file to one `string`.
+
+**Acceptance**:
+
+- `TextDocument.LoadAsync (Stream, ...)` decodes in chunks into the rope.
+- BOM detection uses `StreamReader` and records the detected `Encoding` on the document.
+- Progress reports character count and, when the stream can seek, byte position and total bytes.
+- Cancellation is observed between chunks.
+
+### Streaming document save
+
+As an editor consumer, I can save a document to a stream without materializing `Document.Text`.
+
+**Acceptance**:
+
+- `TextDocument.SaveAsync (Stream, ...)` writes a snapshot in chunks using `TextDocument.Encoding`.
+- DEC-001 holds: mixed line endings are not normalized, so unedited content round-trips byte-identical for
+ the detected encoding/BOM.
+- Progress reports characters written and total characters.
+- Cancellation is observed between chunks.
+
+### Control-level and ted usage
+
+As a `Terminal.Gui.Editor.Editor` / `ted` user, I can use the streaming path without knowing document internals.
+
+**Acceptance**:
+
+- `Editor.LoadAsync` and `Editor.SaveAsync` delegate to the document APIs.
+- `ted` uses stream hooks (`OpenRead`, `CreateWrite`) for File → Open/Save.
+- The ted status bar exposes load/save progress and reports completion.
+- Menu-triggered opens run asynchronously so the UI can render progress before the full file is loaded.
+
+## API
+
+See [`../public-api.md`](../public-api.md) for the public surface and [`../decisions.md`](../decisions.md)
+DEC-009 for placement.
diff --git a/specs/public-api.md b/specs/public-api.md
index f54ba07..b00cf8d 100644
--- a/specs/public-api.md
+++ b/specs/public-api.md
@@ -12,6 +12,15 @@ public class Editor : View
// --- Document ---
public TextDocument Document { get; set; } // exists
public event EventHandler? DocumentChanged; // exists
+ public Task LoadAsync (
+ Stream stream,
+ Encoding? encoding = null,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default); // file-io
+ public Task SaveAsync (
+ Stream stream,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default); // file-io
// --- Caret ---
public int CaretOffset { get; set; } // exists; backed by TextAnchor (caret-anchors ✅)
@@ -108,6 +117,35 @@ public interface IOverlayRenderer
}
```
+## Document File I/O (file-io)
+
+```csharp
+namespace Terminal.Gui.Document;
+
+public sealed class TextDocument
+{
+ public Encoding Encoding { get; set; }
+ public static Task LoadAsync (
+ Stream stream,
+ Encoding? encoding = null,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default);
+ public Task SaveAsync (
+ Stream stream,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default);
+}
+
+public readonly record struct TextDocumentProgress (
+ long CharactersProcessed,
+ long? TotalCharacters = null,
+ long? BytesProcessed = null,
+ long? TotalBytes = null)
+{
+ public double? Fraction { get; }
+}
+```
+
## Change Log
| Date | Change | Feature |
@@ -119,4 +157,5 @@ public interface IOverlayRenderer
| 2026-05-11 | ReadOnly property landed on Editor | read-only |
| 2026-05-12 | `ISearchStrategy?` `SearchStrategy { get; set; }` landed on Editor; string-based FindNext/FindPrevious/ReplaceNext/ReplaceAll overloads retained as convenience wrappers | find-and-replace |
| 2026-05-16 | Vertical multi-caret keybindings (`Ctrl+Alt+CursorUp/Down`, `Alt+Drag`) added via `Editor.DefaultKeyBindings`; no new public Editor API (R8) | vertical-multi-caret |
+| 2026-05-17 | Streaming `TextDocument.LoadAsync` / `TextDocument.SaveAsync`, `TextDocumentProgress`, `TextDocument.Encoding`, and delegating `Editor.LoadAsync` / `Editor.SaveAsync` landed | file-io |
| 2026-05-17 | `Editor` implements `IDesignable`; `EnableForDesign()` seeds C# sample code with syntax highlighting and line numbers | design-time |
diff --git a/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs b/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs
index 5b74953..3497d7f 100644
--- a/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs
+++ b/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs
@@ -15,7 +15,7 @@ public static void ClearPendingPrintableSuppression (IApplication? app)
return;
}
- // Terminal.Gui 2.1.1-develop.98 suppresses the next printable fallback key after parsing
+ // Terminal.Gui suppresses the next printable fallback key after parsing
// ANSI Shift+Tab (ESC [ Z) because Shift+Tab reports Tab as printable text. Until TG exposes
// public input-processor state for this, clear that one-shot suppression after the editor
// handles Unindent so the user's next Tab reaches us. If TG renames this private field, the
diff --git a/src/Terminal.Gui.Editor/Document/TextDocument.cs b/src/Terminal.Gui.Editor/Document/TextDocument.cs
index 597d05e..e52d037 100644
--- a/src/Terminal.Gui.Editor/Document/TextDocument.cs
+++ b/src/Terminal.Gui.Editor/Document/TextDocument.cs
@@ -25,8 +25,10 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
+using System.Text;
using Terminal.Gui.Document.Utils;
using System.Threading;
+using System.Threading.Tasks;
namespace Terminal.Gui.Document
{
@@ -89,6 +91,119 @@ public TextDocument(ITextSource initialText)
{
}
+#nullable enable
+ private Encoding _encoding = new UTF8Encoding (false);
+
+ ///
+ /// Gets or sets the encoding detected during streaming load and used by streaming save.
+ ///
+ public Encoding Encoding
+ {
+ get => _encoding;
+ set => _encoding = value ?? throw new ArgumentNullException (nameof (value));
+ }
+
+ ///
+ /// Streams text from in decoded chunks, invoking (and awaiting)
+ /// for each chunk in order as it is read. Awaiting the callback throttles the
+ /// reader to the consumer's cadence (natural backpressure) and lets a UI consumer apply each chunk
+ /// progressively without ever materializing the whole file as a single string. This method does not create
+ /// or mutate a and has no UI-thread affinity — the consumer decides where and how
+ /// each chunk is applied. See Editor.LoadAsync for the progressive editor consumer.
+ ///
+ /// The source stream.
+ /// Fallback encoding used when no BOM is detected. Defaults to UTF-8 (no BOM).
+ ///
+ /// Invoked and awaited for every decoded chunk, in order. The supplied memory is only valid for the duration
+ /// of the call; copy it if it must outlive the returned .
+ ///
+ /// Invoked once with the encoding the resolved.
+ /// Optional progress sink, reported after each applied chunk.
+ /// Observed between chunks.
+ public static async Task StreamAsync (
+ Stream stream,
+ Encoding? encoding,
+ Func, CancellationToken, ValueTask> onChunk,
+ Action? onEncodingDetected = null,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull (stream);
+ ArgumentNullException.ThrowIfNull (onChunk);
+
+ const int bufferSize = 32 * 1024;
+ Encoding fallbackEncoding = encoding ?? new UTF8Encoding (false);
+ long? totalBytes = stream.CanSeek ? stream.Length : null;
+ char[] buffer = new char[bufferSize];
+ long charactersRead = 0;
+ var encodingReported = false;
+
+ using StreamReader reader = new (
+ stream, fallbackEncoding, detectEncodingFromByteOrderMarks: true, bufferSize, leaveOpen: true);
+
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested ();
+ int read = await reader.ReadAsync (buffer.AsMemory (0, buffer.Length), cancellationToken)
+ .ConfigureAwait (false);
+
+ if (!encodingReported)
+ {
+ // CurrentEncoding is only resolved after the first read consumes (or rules out) a BOM.
+ encodingReported = true;
+ onEncodingDetected?.Invoke (reader.CurrentEncoding);
+ }
+
+ if (read == 0)
+ {
+ break;
+ }
+
+ await onChunk (buffer.AsMemory (0, read), cancellationToken).ConfigureAwait (false);
+ charactersRead += read;
+ progress?.Report (new TextDocumentProgress (charactersRead, null,
+ stream.CanSeek ? stream.Position : null, totalBytes));
+ }
+ }
+
+ ///
+ /// Streams text from into a new without materializing the
+ /// entire document as a single string. The whole stream is consumed before the document is returned, so this
+ /// is not progressive; UI consumers that want incremental rendering should use
+ /// (see Editor.LoadAsync).
+ ///
+ public static async Task LoadAsync (Stream stream, Encoding? encoding = null,
+ IProgress? progress = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull (stream);
+
+ Rope rope = new ();
+ Encoding detected = encoding ?? new UTF8Encoding (false);
+
+ await StreamAsync (
+ stream,
+ encoding,
+ (chunk, _) =>
+ {
+ rope.AddRange (chunk.ToArray (), 0, chunk.Length);
+
+ return ValueTask.CompletedTask;
+ },
+ enc => detected = enc,
+ progress,
+ cancellationToken).ConfigureAwait (false);
+
+ TextDocument document = new (rope)
+ {
+ Encoding = detected
+ };
+ document.UndoStack.MarkAsOriginalFile ();
+ document.SetOwnerThread (null);
+
+ return document;
+ }
+#nullable disable
+
// gets the text from a text source, directly retrieving the underlying rope where possible
private static IEnumerable GetTextFromTextSource(ITextSource textSource)
{
@@ -144,8 +259,9 @@ public ReadOnlyMemory GetTextAsMemory(int offset, int length)
///
///
///
- /// The owner can be set to null, which means that no thread can access the document. But, if the document
- /// has no owner thread, any thread may take ownership by calling .
+ /// The owner can be set to null, which means that the next thread to access the document takes ownership
+ /// on first access. If the document has no owner thread, any thread may also take ownership by calling
+ /// .
///
///
public void SetOwnerThread(Thread newOwner)
@@ -162,6 +278,13 @@ public void SetOwnerThread(Thread newOwner)
private void VerifyAccess()
{
+ if (ownerThread == null)
+ {
+ SetOwnerThread (Thread.CurrentThread);
+
+ return;
+ }
+
if(Thread.CurrentThread != ownerThread)
{
throw new InvalidOperationException("Call from invalid thread.");
@@ -247,6 +370,49 @@ public string Text
}
}
+#nullable enable
+ ///
+ /// Streams the document to using without materializing the
+ /// entire document as a single string.
+ ///
+ public async Task SaveAsync (Stream stream, IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ VerifyAccess ();
+
+ ArgumentNullException.ThrowIfNull (stream);
+
+ const int bufferSize = 32 * 1024;
+ ITextSource snapshot = CreateSnapshot ();
+ SetOwnerThread (null);
+ char[] buffer = new char[bufferSize];
+ long charactersWritten = 0;
+ long totalCharacters = snapshot.TextLength;
+
+ using (TextReader reader = snapshot.CreateReader ())
+ using (StreamWriter writer = new (stream, Encoding, bufferSize, leaveOpen: true))
+ {
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested ();
+ int read = await reader.ReadAsync (buffer.AsMemory (0, buffer.Length), cancellationToken)
+ .ConfigureAwait (false);
+
+ if (read == 0)
+ {
+ break;
+ }
+
+ await writer.WriteAsync (buffer.AsMemory (0, read), cancellationToken).ConfigureAwait (false);
+ charactersWritten += read;
+ progress?.Report (new TextDocumentProgress (charactersWritten, totalCharacters));
+ }
+
+ await writer.FlushAsync (cancellationToken).ConfigureAwait (false);
+ }
+ }
+#nullable disable
+
///
/// This event is called after a group of changes is completed.
///
diff --git a/src/Terminal.Gui.Editor/Document/TextDocumentProgress.cs b/src/Terminal.Gui.Editor/Document/TextDocumentProgress.cs
new file mode 100644
index 0000000..52dd3f4
--- /dev/null
+++ b/src/Terminal.Gui.Editor/Document/TextDocumentProgress.cs
@@ -0,0 +1,21 @@
+namespace Terminal.Gui.Document;
+
+/// Reports streaming document I/O progress.
+/// The number of decoded characters loaded or saved so far.
+/// The total character count, when known.
+/// The number of bytes consumed so far, when known.
+/// The total byte count, when known.
+public readonly record struct TextDocumentProgress (
+ long CharactersProcessed,
+ long? TotalCharacters = null,
+ long? BytesProcessed = null,
+ long? TotalBytes = null)
+{
+ /// Gets the best available completion fraction, or when no total is known.
+ public double? Fraction =>
+ TotalBytes is > 0 && BytesProcessed is { } bytesProcessed
+ ? Math.Clamp ((double)bytesProcessed / TotalBytes.Value, 0, 1)
+ : TotalCharacters is > 0
+ ? Math.Clamp ((double)CharactersProcessed / TotalCharacters.Value, 0, 1)
+ : null;
+}
diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs
index 23018c6..80bda8f 100644
--- a/src/Terminal.Gui.Editor/Editor.Drawing.cs
+++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs
@@ -31,6 +31,16 @@ protected override bool OnDrawingContent (DrawContext? context)
EnsureColorizerAttribute (normal);
DrawVisibleLines (viewport, normal, selected);
+
+ if (_maxWidthGrewDuringDraw)
+ {
+ // A rendered line was wider than the estimate; resize content (cheap — not _maxWidthDirty)
+ // so the horizontal scrollbar reflects what's now on screen. Monotonic: once the widest
+ // visible line is measured this stops firing, so no draw/layout loop.
+ _maxWidthGrewDuringDraw = false;
+ UpdateContentSize ();
+ }
+
SetAttribute (normal);
UpdateCursor ();
@@ -311,6 +321,16 @@ private void DrawVisualLine (
// two don't thrash each other's entries (they use different attribute sets).
CellVisualLine visualLine = GetOrBuildDrawVisualLine (line, segments, normal, selected, selStart, selEnd);
+ // A line we actually render gives us its exact width for free. If the running max was an
+ // estimate (large document) and this visible line is wider, grow the extent — reconciled
+ // once after the draw in OnDrawingContent so the horizontal scrollbar tracks visible content.
+ if (!WordWrap && visualLine.VisualLength > _maxVisualWidth)
+ {
+ _maxVisualWidth = visualLine.VisualLength;
+ _maxWidthLineNumber = line.LineNumber;
+ _maxWidthGrewDuringDraw = true;
+ }
+
foreach (IBackgroundRenderer renderer in BackgroundRenderers)
{
renderer.Draw (this, visualLine, row, Viewport);
diff --git a/src/Terminal.Gui.Editor/Editor.FileIO.cs b/src/Terminal.Gui.Editor/Editor.FileIO.cs
new file mode 100644
index 0000000..6e95d20
--- /dev/null
+++ b/src/Terminal.Gui.Editor/Editor.FileIO.cs
@@ -0,0 +1,212 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Terminal.Gui.Document;
+
+namespace Terminal.Gui.Editor;
+
+public partial class Editor
+{
+ // Chars buffered before the very first paint. Small so the first screenful appears almost immediately —
+ // the whole point of progressive load.
+ private const int FirstFlushChars = 16 * 1024;
+
+ // Chars buffered between subsequent paints. Each flush is one marshalled append + a visible-viewport
+ // redraw, so this only needs to be large enough to avoid an excessive number of round-trips. 64 KiB
+ // makes a 10 MiB file fill in ~160 smooth steps; 256 KiB felt chunky.
+ private const int SubsequentFlushChars = 64 * 1024;
+
+ ///
+ /// Streams text from into without ever materializing the
+ /// whole file as a single string (resolves OPEN-003 / DEC-009).
+ ///
+ /// When is supplied (UI consumers such as ted), an empty document is
+ /// installed and painted immediately and decoded chunks are appended on the UI thread as they
+ /// arrive, so the editor fills in top-down while staying responsive. When is
+ /// (synchronous callers / tests) the file is read in chunks and the document is
+ /// installed once at the end.
+ ///
+ ///
+ /// The source stream.
+ /// Fallback encoding when no BOM is detected. Defaults to UTF-8 (no BOM).
+ /// Optional progress sink.
+ /// Cancels the load between chunks.
+ ///
+ /// Marshals an action onto the UI thread and completes when it has run. UI consumers pass their
+ /// application-invoke helper so the read happens on a background thread while every document mutation
+ /// happens on the UI thread.
+ ///
+ public async Task LoadAsync (
+ Stream stream,
+ Encoding? encoding = null,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default,
+ Func? marshal = null)
+ {
+ ArgumentNullException.ThrowIfNull (stream);
+
+ if (marshal is null)
+ {
+ await LoadNonProgressiveAsync (stream, encoding, progress, cancellationToken).ConfigureAwait (false);
+
+ return;
+ }
+
+ await LoadProgressiveAsync (stream, encoding, progress, marshal, cancellationToken).ConfigureAwait (false);
+ }
+
+ // Synchronous / non-UI path: build the whole document off-thread in chunks (no giant string), then install
+ // it once. The document's owner thread is released at the end so the next single consumer (the caller, a
+ // test, the UI) claims it on first access — matches the pre-progressive idiom and TextDocument's affinity.
+ private async Task LoadNonProgressiveAsync (
+ Stream stream,
+ Encoding? encoding,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ Document?.SetOwnerThread (null);
+
+ TextDocument document =
+ await TextDocument.LoadAsync (stream, encoding, progress, cancellationToken).ConfigureAwait (false);
+
+ document.SetOwnerThread (Thread.CurrentThread);
+ Document = document;
+ CaretOffset = 0;
+ document.SetOwnerThread (null);
+ }
+
+ // UI path: install an empty document immediately so the editor paints, then append decoded chunks on the UI
+ // thread as they stream in. The read/decode runs on a background thread; the document is only ever touched
+ // on the UI thread (assign here, every append + the finalize marshalled).
+ private async Task LoadProgressiveAsync (
+ Stream stream,
+ Encoding? encoding,
+ IProgress? progress,
+ Func marshal,
+ CancellationToken cancellationToken)
+ {
+ Document?.SetOwnerThread (null);
+ TextDocument document = new ();
+ document.SetOwnerThread (Thread.CurrentThread);
+
+ bool priorReadOnly = ReadOnly;
+ Document = document;
+ CaretOffset = 0;
+
+ // Read-only while streaming: the background reader appends at the end; user edits at arbitrary offsets
+ // would race those appends. The buffer becomes editable the moment the load completes.
+ ReadOnly = true;
+
+ Encoding detected = encoding ?? new UTF8Encoding (false);
+ var firstFlushDone = false;
+ var pending = new StringBuilder (SubsequentFlushChars + FirstFlushChars);
+
+ void AppendOnUiThread (string text)
+ {
+ // The user opened a different file (or LoadAsync was called again) while this load was in flight —
+ // stop feeding a document the editor no longer shows.
+ if (!ReferenceEquals (_document, document))
+ {
+ throw new OperationCanceledException ();
+ }
+
+ document.Insert (document.TextLength, text);
+
+ // Keep the caret pinned to the top so the viewport stays put on the first screenful while the rest
+ // streams in below, off-screen. Without this the AfterInsertion caret anchor rides every tail append
+ // and EnsureCaretVisible follows it, scrolling the view for the whole load. Tail-follow is a host
+ // policy (a host can scroll / set CaretOffset on progress); the editor's default is a stable viewport.
+ CaretOffset = 0;
+ SetNeedsDraw ();
+ }
+
+ async ValueTask OnChunk (ReadOnlyMemory chunk, CancellationToken token)
+ {
+ pending.Append (chunk.Span);
+
+ int threshold = firstFlushDone ? SubsequentFlushChars : FirstFlushChars;
+
+ if (pending.Length < threshold)
+ {
+ return;
+ }
+
+ string text = pending.ToString ();
+ pending.Clear ();
+ firstFlushDone = true;
+ await marshal (() => AppendOnUiThread (text)).ConfigureAwait (false);
+ }
+
+ try
+ {
+ // Read + decode off the UI thread; OnChunk marshals each flush back onto it.
+ await Task.Run (
+ () => TextDocument.StreamAsync (
+ stream,
+ encoding,
+ OnChunk,
+ enc => detected = enc,
+ progress,
+ cancellationToken),
+ cancellationToken).ConfigureAwait (false);
+
+ await marshal (
+ () =>
+ {
+ if (!ReferenceEquals (_document, document))
+ {
+ return;
+ }
+
+ if (pending.Length > 0)
+ {
+ document.Insert (document.TextLength, pending.ToString ());
+ pending.Clear ();
+ }
+
+ document.Encoding = detected;
+
+ // Loading is not an undoable edit (matches every editor); discard the transient append
+ // history and mark the freshly loaded content as the pristine on-disk state.
+ document.UndoStack.ClearAll ();
+ document.UndoStack.MarkAsOriginalFile ();
+
+ ReadOnly = priorReadOnly;
+ CaretOffset = 0;
+ SetNeedsDraw ();
+ }).ConfigureAwait (false);
+ }
+ catch
+ {
+ // Restore editability even on cancel/failure; keep whatever streamed in so far.
+ await marshal (
+ () =>
+ {
+ if (ReferenceEquals (_document, document))
+ {
+ ReadOnly = priorReadOnly;
+ }
+ }).ConfigureAwait (false);
+
+ throw;
+ }
+ }
+
+ ///
+ /// Streams to by delegating to
+ /// .
+ ///
+ public Task SaveAsync (
+ Stream stream,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default)
+ {
+ TextDocument document = Document
+ ?? throw new InvalidOperationException (
+ "Cannot save because the editor has no document.");
+
+ return document.SaveAsync (stream, progress, cancellationToken);
+ }
+}
diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs
index b924bcb..574e494 100644
--- a/src/Terminal.Gui.Editor/Editor.cs
+++ b/src/Terminal.Gui.Editor/Editor.cs
@@ -73,6 +73,18 @@ public partial class Editor : View
private bool _maxWidthDirty = true;
private int _maxWidthLineNumber;
+ // Above this document size the horizontal extent is estimated from each line's character count
+ // (O(1) per line) instead of building + syntax-highlighting a CellVisualLine for every line.
+ // Building every line on load is what made a 10 MiB open take ~10 s; the model layer alone
+ // loads in ~0.2 s. Smaller documents keep the exact computation (tab/wide-glyph precise).
+ private const int MaxWidthEstimateThresholdBytes = 256 * 1024;
+
+ // Set by the draw path when a rendered line turned out wider than the running max (e.g. an
+ // estimated large doc whose visible tab-indented line expands past the char-length estimate).
+ // OnDrawingContent reconciles the content size once after the draw, so the horizontal scrollbar
+ // grows as wider lines scroll into view — matching VS Code's "estimate, then refine" model.
+ private bool _maxWidthGrewDuringDraw;
+
///
/// Sticky column for vertical caret moves. Tracks the column the user *intends* to be in,
/// even when the current line is shorter, so Up/Down across short lines snap back to the
@@ -456,7 +468,8 @@ protected override void Dispose (bool disposing)
// external code retains the TextDocument (test fixtures, future shared docs across panes,
// etc.). The Document setter unsubscribes on swap; this covers View-teardown.
_document.Changed -= OnDocumentChanged;
- _lastKnownCaretOffset = CaretOffset;
+ // Dispose can run after document ownership moved; _lastKnownCaretOffset is maintained
+ // during caret movement and document changes, so avoid reading CaretOffset here.
_caretAnchor = null;
_selectionAnchor = null;
_additionalCarets.Clear ();
@@ -526,6 +539,25 @@ private void UpdateContentSize ()
SetContentSize (new Size (_maxVisualWidth + 1, Math.Max (1, visibleLines)));
}
+ ///
+ /// Per-line horizontal extent used for the content width / horizontal scrollbar. For documents
+ /// below this is the exact visual width (builds the
+ /// line, tab/wide-glyph precise). For larger documents it is the line's character count — O(1),
+ /// no build, no syntax-highlight — so opening a multi-MB file does not build + highlight every
+ /// line just to size a scrollbar. The estimate can be short for tab-indented / wide-glyph lines;
+ /// the draw path refines exactly for lines it actually renders, so
+ /// visible content always has a correct extent and the scrollbar grows on scroll.
+ ///
+ private int MeasureLineWidth (DocumentLine line)
+ {
+ if (_document is { } document && document.TextLength >= MaxWidthEstimateThresholdBytes)
+ {
+ return line.Length;
+ }
+
+ return GetOrBuildDefaultVisualLine (line).VisualLength;
+ }
+
/// Full O(N) recompute — only called on Document swap, IndentationSize change, etc.
private void RecomputeMaxWidth ()
{
@@ -541,7 +573,7 @@ private void RecomputeMaxWidth ()
foreach (DocumentLine line in _document.Lines)
{
- var width = GetOrBuildDefaultVisualLine (line).VisualLength;
+ var width = MeasureLineWidth (line);
if (width > _maxVisualWidth)
{
@@ -587,7 +619,7 @@ private void UpdateMaxWidthIncremental (DocumentChangeEventArgs e)
for (var lineNum = firstAffected.LineNumber; lineNum <= scanEnd; lineNum++)
{
DocumentLine line = _document.GetLineByNumber (lineNum);
- var width = GetOrBuildDefaultVisualLine (line).VisualLength;
+ var width = MeasureLineWidth (line);
if (width >= newMax)
{
@@ -618,7 +650,7 @@ private void UpdateMaxWidthIncremental (DocumentChangeEventArgs e)
for (var lineNum = firstAffected.LineNumber; lineNum <= endLine; lineNum++)
{
DocumentLine line = _document.GetLineByNumber (lineNum);
- var width = GetOrBuildDefaultVisualLine (line).VisualLength;
+ var width = MeasureLineWidth (line);
if (width > _maxVisualWidth)
{
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs
index 1d6dc62..93ea325 100644
--- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs
@@ -148,9 +148,9 @@ public async Task Backspace_At_End_Of_Leading_Whitespace_Removes_One_Indentation
}
[Fact]
- public async Task Ted_RawAnsi_Tab_After_ShiftTab_Reindents_Line_On_First_Keypress ()
+ public async Task RawAnsi_Tab_After_ShiftTab_Reindents_Line_On_First_Keypress ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
fx.Top.Editor.SetFocus ();
fx.Top.Editor.Document!.Text = "hello world";
fx.Top.Editor.CaretOffset = 0;
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs
index af53462..b9d2747 100644
--- a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs
@@ -2,6 +2,8 @@
using System.Collections.Immutable;
using System.Drawing;
+using System.IO;
+using System.Text;
using Ted;
using Terminal.Gui.Configuration;
using Terminal.Gui.Editor.IntegrationTests.Testing;
@@ -29,9 +31,9 @@ private static void DeleteIfExists (string filePath)
[Fact]
public void NewFile_ClearsEditor_AndCurrentFilePath ()
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.ShowOpenDialog = () => "/tmp/ted-open.txt";
- app.ReadAllText = _ => "opened";
+ app.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes ("opened"));
Assert.True (app.OpenFile ());
app.Editor.SelectAll ();
@@ -47,9 +49,9 @@ public void NewFile_ClearsEditor_AndCurrentFilePath ()
[Fact]
public void OpenFile_Canceled_DoesNotChangeEditor ()
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.ShowOpenDialog = () => null;
- app.ReadAllText = _ => throw new InvalidOperationException ("Canceled open should not read.");
+ app.OpenRead = _ => throw new InvalidOperationException ("Canceled open should not read.");
Assert.False (app.OpenFile ());
@@ -65,7 +67,7 @@ public void OpenFile_LoadsSelectedFile_FromDisk ()
try
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.ShowOpenDialog = () => filePath;
Assert.True (app.OpenFile ());
@@ -88,7 +90,7 @@ public void OpenMissingFile_SetsPath_AndMarksDocumentModified ()
try
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.OpenMissingFile (filePath);
Assert.Equal (filePath, app.CurrentFilePath);
@@ -101,6 +103,150 @@ public void OpenMissingFile_SetsPath_AndMarksDocumentModified ()
}
}
+ [Fact]
+ public async Task OpenFileAsync_Updates_LoadStatusShortcut ()
+ {
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
+ app.ShowOpenDialog = () => "/tmp/ted-progress.txt";
+ app.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes (new string ('x', 100_000)));
+
+ Assert.Equal (string.Empty, app.LoadSpinnerShortcut.Title);
+
+ Assert.True (await app.OpenFileAsync (TestContext.Current.CancellationToken));
+
+ Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.Title);
+ Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.HelpText);
+ Assert.Same (app.LoadStatusSpinner, app.LoadSpinnerShortcut.CommandView);
+ Assert.False (app.LoadStatusSpinner.Visible);
+ Assert.False (app.LoadStatusSpinner.AutoSpin);
+ Assert.Equal (100_000, app.Editor.Document!.TextLength);
+ }
+
+ [Fact]
+ public async Task OpenFileAsync_Loads_Stream_On_Background_Thread ()
+ {
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
+ var testThreadId = Environment.CurrentManagedThreadId;
+ GatedReadStream stream = new (Encoding.UTF8.GetBytes (new string ('x', 100_000)));
+ app.ShowOpenDialog = () => "/tmp/ted-progress.txt";
+ app.OpenRead = _ => stream;
+
+ Task openTask = app.OpenFileAsync (TestContext.Current.CancellationToken);
+
+ await stream.ReadStarted.Task.WaitAsync (TestContext.Current.CancellationToken);
+
+ Assert.False (openTask.IsCompleted);
+ Assert.True (app.LoadStatusSpinner.Visible);
+ Assert.True (app.LoadStatusSpinner.AutoSpin);
+ Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.Title);
+ Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.HelpText);
+
+ stream.AllowRead.SetResult ();
+
+ Assert.True (await openTask);
+ Assert.NotEqual (testThreadId, stream.ReadThreadId);
+ Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.Title);
+ Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.HelpText);
+ }
+
+ [Fact]
+ public async Task OpenFileAsync_ReadFailure_HidesSpinner_AndShowsFailureStatus ()
+ {
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
+ app.ShowOpenDialog = () => "/tmp/ted-open-fails.txt";
+ app.OpenRead = _ => new ThrowingReadStream ();
+
+ Assert.False (await app.OpenFileAsync (TestContext.Current.CancellationToken));
+
+ Assert.False (app.LoadStatusSpinner.Visible);
+ Assert.False (app.LoadStatusSpinner.AutoSpin);
+ Assert.Equal ("Load failed", app.LoadSpinnerShortcut.Title);
+ Assert.Equal ("Load failed", app.LoadSpinnerShortcut.HelpText);
+ }
+
+ [Fact]
+ public void OpenFile_UsesReadAllTextHook_WhenReplaced ()
+ {
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
+ app.ShowOpenDialog = () => "/tmp/ted-legacy-open.txt";
+ app.ReadAllText = path => path == "/tmp/ted-legacy-open.txt" ? "legacy open" : string.Empty;
+
+ Assert.True (app.OpenFile ());
+
+ Assert.Equal ("legacy open", app.Editor.Document!.Text);
+ Assert.Equal ("/tmp/ted-legacy-open.txt", app.CurrentFilePath);
+ }
+
+ [Fact]
+ public async Task OpenFileAsync_ByPath_Updates_LoadStatusShortcut ()
+ {
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
+ GatedReadStream stream = new (Encoding.UTF8.GetBytes (new string ('x', 100_000)));
+ app.OpenRead = _ => stream;
+
+ Task openTask = app.OpenFileAsync ("/tmp/ted-progress.cs", TestContext.Current.CancellationToken);
+
+ await stream.ReadStarted.Task.WaitAsync (TestContext.Current.CancellationToken);
+
+ Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.Title);
+ Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.HelpText);
+
+ stream.AllowRead.SetResult ();
+
+ Assert.True (await openTask);
+ Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.Title);
+ Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.HelpText);
+ Assert.False (app.LoadStatusSpinner.Visible);
+ Assert.False (app.LoadStatusSpinner.AutoSpin);
+ }
+
+ [Fact]
+ public async Task StatusBar_Shows_Loaded_FileSize_After_StartupOpen ()
+ {
+ var filePath = Path.Combine (Path.GetTempPath (), $"ted-startup-{Guid.NewGuid ():N}.cs");
+ await File.WriteAllTextAsync (filePath, new string ('x', 100_000), TestContext.Current.CancellationToken);
+
+ try
+ {
+ await using AppFixture fx = new (() =>
+ {
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
+ app.OpenFileAsync (filePath).GetAwaiter ().GetResult ();
+
+ return app;
+ });
+
+ fx.Render ();
+
+ DriverAssert.ContentsContains (fx.Driver, "Loaded 97.7 KiB");
+ }
+ finally
+ {
+ File.Delete (filePath);
+ }
+ }
+
+ [Fact]
+ public async Task OpenFileAsync_LargeFile_DisablesAutomaticFolding ()
+ {
+ var filePath = Path.Combine (Path.GetTempPath (), $"ted-large-{Guid.NewGuid ():N}.cs");
+ await File.WriteAllTextAsync (filePath, new string ('x', 1_000_001), TestContext.Current.CancellationToken);
+
+ try
+ {
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
+
+ Assert.True (await app.OpenFileAsync (filePath, TestContext.Current.CancellationToken));
+
+ Assert.Null (app.Editor.FoldingManager);
+ Assert.Equal ("Loaded 976.6 KiB", app.LoadSpinnerShortcut.Title);
+ }
+ finally
+ {
+ File.Delete (filePath);
+ }
+ }
+
[Fact]
public void SaveFile_WritesCurrentEditorText_ToCurrentPath ()
{
@@ -109,7 +255,7 @@ public void SaveFile_WritesCurrentEditorText_ToCurrentPath ()
try
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.ShowOpenDialog = () => filePath;
Assert.True (app.OpenFile ());
app.Editor.Document!.Text = "after";
@@ -128,12 +274,12 @@ public void SaveFile_WritesCurrentEditorText_ToCurrentPath ()
[Fact]
public void SaveFile_MarksDocumentUnmodified ()
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.ShowOpenDialog = () => "/tmp/ted-save.txt";
- app.ReadAllText = _ => "before";
+ app.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes ("before"));
Assert.True (app.OpenFile ());
app.Editor.Document!.Text = "after";
- app.WriteAllText = (_, _) => { };
+ app.CreateWrite = _ => new MemoryStream ();
Assert.True (app.IsDocumentModified);
@@ -142,6 +288,71 @@ public void SaveFile_MarksDocumentUnmodified ()
Assert.False (app.IsDocumentModified);
}
+ [Fact]
+ public async Task SaveFileAsync_WriteFailure_HidesSpinner_AndShowsFailureStatus ()
+ {
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
+ app.OpenMissingFile ("/tmp/ted-save-fails.txt");
+ app.Editor.Document!.Text = "dirty";
+ app.CreateWrite = _ => new ThrowingWriteStream ();
+
+ Assert.False (await app.SaveFileAsync (TestContext.Current.CancellationToken));
+
+ Assert.False (app.LoadStatusSpinner.Visible);
+ Assert.False (app.LoadStatusSpinner.AutoSpin);
+ Assert.Equal ("Save failed", app.LoadSpinnerShortcut.Title);
+ Assert.Equal ("Save failed", app.LoadSpinnerShortcut.HelpText);
+ Assert.True (app.IsDocumentModified);
+ }
+
+ [Fact]
+ public async Task SaveFileAsync_Canceled_HidesSpinner_AndShowsCanceledStatus ()
+ {
+ var filePath = Path.Combine (Path.GetTempPath (), $"ted-save-canceled-{Guid.NewGuid ():N}.txt");
+
+ try
+ {
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
+ app.OpenMissingFile (filePath);
+ app.Editor.Document!.Text = "dirty";
+ using CancellationTokenSource cts = new ();
+ await cts.CancelAsync ();
+
+ Assert.False (await app.SaveFileAsync (cts.Token));
+
+ Assert.False (app.LoadStatusSpinner.Visible);
+ Assert.False (app.LoadStatusSpinner.AutoSpin);
+ Assert.Equal ("Save canceled", app.LoadSpinnerShortcut.Title);
+ Assert.Equal ("Save canceled", app.LoadSpinnerShortcut.HelpText);
+ Assert.True (app.IsDocumentModified);
+ }
+ finally
+ {
+ DeleteIfExists (filePath);
+ }
+ }
+
+ [Fact]
+ public void SaveFile_UsesWriteAllTextHook_WhenReplaced ()
+ {
+ string? savedPath = null;
+ string? savedText = null;
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
+ app.OpenMissingFile ("/tmp/ted-legacy-save.txt");
+ app.Editor.Document!.Text = "legacy save";
+ app.WriteAllText = (path, text) =>
+ {
+ savedPath = path;
+ savedText = text;
+ };
+
+ Assert.True (app.SaveFile ());
+
+ Assert.Equal ("/tmp/ted-legacy-save.txt", savedPath);
+ Assert.Equal ("legacy save", savedText);
+ Assert.False (app.IsDocumentModified);
+ }
+
[Fact]
public void Open_Save_RoundTrip_Preserves_Tab_Characters ()
{
@@ -150,7 +361,7 @@ public void Open_Save_RoundTrip_Preserves_Tab_Characters ()
try
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.ShowOpenDialog = () => filePath;
Assert.True (app.OpenFile ());
@@ -168,9 +379,14 @@ public void Open_Save_RoundTrip_Preserves_Tab_Characters ()
public void SaveFileAs_Canceled_DoesNotWrite ()
{
var wrote = false;
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.ShowSaveDialog = () => " ";
- app.WriteAllText = (_, _) => wrote = true;
+ app.CreateWrite = _ =>
+ {
+ wrote = true;
+
+ return new MemoryStream ();
+ };
Assert.False (app.SaveFileAs ());
@@ -185,7 +401,7 @@ public void SaveFileAs_WritesEditorText_ToSelectedPath ()
try
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.ShowSaveDialog = () => filePath;
app.Editor.Document!.Text = "save as";
@@ -204,7 +420,7 @@ public void SaveFileAs_WritesEditorText_ToSelectedPath ()
public void QuitFile_ModifiedDocument_CancelChoice_DoesNotQuit ()
{
var prompted = false;
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.Editor.Document!.Text = "dirty";
app.ShowSaveChangesDialog = () =>
{
@@ -222,18 +438,19 @@ public void QuitFile_ModifiedDocument_CancelChoice_DoesNotQuit ()
[Fact]
public async Task QuitFile_ModifiedDocument_SaveChoice_SavesBeforeQuitting ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
string? savedPath = null;
string? savedText = null;
fx.Top.ShowOpenDialog = () => "/tmp/ted-save-on-quit.txt";
- fx.Top.ReadAllText = _ => "before";
+ fx.Top.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes ("before"));
Assert.True (fx.Top.OpenFile ());
fx.Top.Editor.Document!.Text = "after";
fx.Top.ShowSaveChangesDialog = () => SaveChangesChoice.Save;
- fx.Top.WriteAllText = (path, text) =>
+ fx.Top.CreateWrite = path =>
{
savedPath = path;
- savedText = text;
+
+ return new CapturingWriteStream (text => savedText = text);
};
Assert.True (fx.Top.QuitFile ());
@@ -251,7 +468,7 @@ public void QuitFile_MissingFile_DiscardChoice_DoesNotCreateFile ()
try
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.OpenMissingFile (filePath);
app.ShowSaveChangesDialog = () => SaveChangesChoice.Discard;
@@ -272,7 +489,7 @@ public void QuitFile_MissingFile_SaveChoice_CreatesEmptyFile ()
try
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.OpenMissingFile (filePath);
app.ShowSaveChangesDialog = () => SaveChangesChoice.Save;
@@ -291,7 +508,7 @@ public void QuitFile_MissingFile_SaveChoice_CreatesEmptyFile ()
[Fact]
public async Task Renders_FileMenu_Header ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
DriverAssert.ContentsContains (fx.Driver, "File");
}
@@ -299,7 +516,7 @@ public async Task Renders_FileMenu_Header ()
[Fact]
public async Task Renders_OptionsMenu_Header ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
DriverAssert.ContentsContains (fx.Driver, "Options");
}
@@ -307,7 +524,7 @@ public async Task Renders_OptionsMenu_Header ()
[Fact]
public async Task Renders_ViewMenu_Header ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
DriverAssert.ContentsContains (fx.Driver, "View");
}
@@ -315,7 +532,7 @@ public async Task Renders_ViewMenu_Header ()
[Fact]
public async Task Constructor_Defaults_To_Plain_Text_Highlighting ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
Assert.Null (fx.Top.Editor.HighlightingDefinition);
Assert.Equal ("Plain Text", fx.Top.LanguageShortcut.Title);
@@ -328,7 +545,7 @@ public async Task Highlighting_Auto_Detects_From_File_Extension ()
try
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
fx.Top.OpenMissingFile (tempXmlFilePath);
Assert.NotNull (fx.Top.Editor.HighlightingDefinition);
@@ -344,7 +561,7 @@ public async Task Highlighting_Auto_Detects_From_File_Extension ()
[Fact]
public async Task OptionsMenu_Contains_Settings_Item ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct };
fx.Injector.InjectKey (Key.O.WithAlt, options);
@@ -364,7 +581,7 @@ public void Constructor_ReadOnly_Sets_Editor_ReadOnly ()
[Fact]
public void Constructor_Defaults_AutoIndent_To_Enabled ()
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
Assert.IsType (app.Editor.IndentationStrategy);
}
@@ -372,7 +589,7 @@ public void Constructor_Defaults_AutoIndent_To_Enabled ()
[Fact]
public async Task Loc_StatusBar_Shortcut_Initially_Shows_Line_1_Column_1 ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
Assert.Equal ("Ln 1, Col 1", fx.Top.LocShortcut.Title);
}
@@ -382,7 +599,7 @@ public async Task Loc_StatusBar_Shortcut_Tracks_Caret_Movement ()
{
await using AppFixture fx = new (() =>
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.Editor.Document!.Text = "alpha\nbeta\ngamma";
return app;
@@ -399,7 +616,7 @@ public async Task Loc_StatusBar_Shortcut_Updates_When_Document_Edit_Shifts_Caret
{
await using AppFixture fx = new (() =>
{
- TedApp app = new ();
+ TedApp app = new (configPath: TedTestConfig.NewPath ());
app.Editor.Document!.Text = "abc";
return app;
@@ -416,7 +633,7 @@ public async Task Loc_StatusBar_Shortcut_Updates_When_Document_Edit_Shifts_Caret
[Fact]
public async Task FileMenu_OpensViaKeyboard_AltF ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
// The "Open..." menu item is unique to the dropdown — the StatusBar shortcut is just "Open".
DriverAssert.ContentsDoesNotContain (fx.Driver, "Open...");
@@ -431,7 +648,7 @@ public async Task FileMenu_OpensViaKeyboard_AltF ()
[Fact]
public async Task ViewMenu_TogglesLineNumbers_ViaKeyboard ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
Assert.True (fx.Top.Editor.GutterOptions.HasFlag (GutterOptions.LineNumbers));
InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct };
@@ -461,7 +678,7 @@ public async Task ViewMenu_TogglesLineNumbers_ViaKeyboard ()
[Fact]
public async Task FileMenu_OpensViaMouse_ClickOnHeader ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
DriverAssert.ContentsDoesNotContain (fx.Driver, "Open...");
@@ -486,7 +703,7 @@ public async Task FileMenu_OpensViaMouse_ClickOnHeader ()
[Fact]
public async Task EditMenu_OpensViaKeyboard_AltE_Contains_Find_And_Replace ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
DriverAssert.ContentsDoesNotContain (fx.Driver, "Find...");
DriverAssert.ContentsDoesNotContain (fx.Driver, "Replace...");
@@ -502,7 +719,7 @@ public async Task EditMenu_OpensViaKeyboard_AltE_Contains_Find_And_Replace ()
[Fact]
public async Task Editor_RightClick_Opens_Edit_Context_Menu ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
DriverAssert.ContentsDoesNotContain (fx.Driver, "Select all");
@@ -526,7 +743,7 @@ public async Task Editor_RightClick_Opens_Edit_Context_Menu ()
[Fact]
public async Task ThemeDropDown_Initially_Shows_Current_Theme ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
Assert.Equal (ThemeManager.Theme, fx.Top.ThemeDropDown.Text);
}
@@ -534,7 +751,7 @@ public async Task ThemeDropDown_Initially_Shows_Current_Theme ()
[Fact]
public async Task ThemeDropDown_Source_Contains_All_Available_Themes ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
ImmutableList expected = ThemeManager.GetThemeNames ();
Assert.True (expected.Count > 0, "ThemeManager should expose at least one theme.");
@@ -549,7 +766,7 @@ public async Task ThemeDropDown_Source_Contains_All_Available_Themes ()
[Fact]
public async Task ThemeDropDown_Selection_Changes_Active_Theme ()
{
- await using AppFixture fx = new (() => new TedApp ());
+ await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ()));
ImmutableList names = ThemeManager.GetThemeNames ();
@@ -566,4 +783,115 @@ public async Task ThemeDropDown_Selection_Changes_Active_Theme ()
Assert.Equal (target, ThemeManager.Theme);
}
+
+ private sealed class CapturingWriteStream : MemoryStream
+ {
+ private readonly Action _capture;
+
+ public CapturingWriteStream (Action capture)
+ {
+ _capture = capture;
+ }
+
+ protected override void Dispose (bool disposing)
+ {
+ if (disposing)
+ {
+ _capture (Encoding.UTF8.GetString (ToArray ()));
+ }
+
+ base.Dispose (disposing);
+ }
+
+ public override async ValueTask DisposeAsync ()
+ {
+ _capture (Encoding.UTF8.GetString (ToArray ()));
+ await base.DisposeAsync ();
+ }
+ }
+
+ private sealed class ThrowingReadStream : Stream
+ {
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => 1;
+
+ public override long Position { get; set; }
+
+ public override void Flush ()
+ {
+ }
+
+ public override int Read (byte[] buffer, int offset, int count)
+ {
+ throw new IOException ("read failed");
+ }
+
+ public override ValueTask ReadAsync (Memory buffer, CancellationToken cancellationToken = default)
+ {
+ throw new IOException ("read failed");
+ }
+
+ public override long Seek (long offset, SeekOrigin origin)
+ {
+ throw new NotSupportedException ();
+ }
+
+ public override void SetLength (long value)
+ {
+ throw new NotSupportedException ();
+ }
+
+ public override void Write (byte[] buffer, int offset, int count)
+ {
+ throw new NotSupportedException ();
+ }
+ }
+
+ private sealed class ThrowingWriteStream : MemoryStream
+ {
+ public override void Write (byte[] buffer, int offset, int count)
+ {
+ throw new IOException ("write failed");
+ }
+
+ public override ValueTask WriteAsync (ReadOnlyMemory buffer, CancellationToken cancellationToken = default)
+ {
+ throw new IOException ("write failed");
+ }
+ }
+
+ /// Gates async reads and captures the reading thread ID for background-load tests.
+ private sealed class GatedReadStream : MemoryStream
+ {
+ public GatedReadStream (byte[] buffer)
+ : base (buffer)
+ {
+ }
+
+ public TaskCompletionSource AllowRead { get; } = new (TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public TaskCompletionSource ReadStarted { get; } = new (TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public int ReadThreadId { get; private set; }
+
+ public override ValueTask ReadAsync (Memory buffer, CancellationToken cancellationToken = default)
+ {
+ ReadThreadId = Environment.CurrentManagedThreadId;
+ ReadStarted.TrySetResult ();
+
+ return new ValueTask (ReadAfterGateAsync (buffer, cancellationToken));
+ }
+
+ private async Task ReadAfterGateAsync (Memory buffer, CancellationToken cancellationToken)
+ {
+ await AllowRead.Task.WaitAsync (cancellationToken);
+
+ return await base.ReadAsync (buffer, cancellationToken);
+ }
+ }
}
diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TedTestConfig.cs b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TedTestConfig.cs
new file mode 100644
index 0000000..40921ca
--- /dev/null
+++ b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TedTestConfig.cs
@@ -0,0 +1,23 @@
+using System;
+using System.IO;
+
+namespace Terminal.Gui.Editor.IntegrationTests.Testing;
+
+///
+/// Supplies a unique throwaway ted.config.json path under the OS temp directory for
+/// TedApp construction in tests, so a real TedApp exercising menu/dialog actions
+/// persists view settings there instead of polluting the developer's real
+/// ~/.tui/ted.config.json. Per-instance (passed to the TedApp constructor) — no
+/// environment-variable or static mutation, so it stays parallel-safe.
+///
+internal static class TedTestConfig
+{
+ internal static string NewPath ()
+ {
+ return Path.Combine (
+ Path.GetTempPath (),
+ "ted-tests",
+ Guid.NewGuid ().ToString ("N"),
+ "ted.config.json");
+ }
+}
diff --git a/tests/Terminal.Gui.Editor.PerformanceTests/LargeDocumentLoadPerformanceTests.cs b/tests/Terminal.Gui.Editor.PerformanceTests/LargeDocumentLoadPerformanceTests.cs
new file mode 100644
index 0000000..0a90cda
--- /dev/null
+++ b/tests/Terminal.Gui.Editor.PerformanceTests/LargeDocumentLoadPerformanceTests.cs
@@ -0,0 +1,46 @@
+using System.Diagnostics;
+using System.Text;
+using Terminal.Gui.Document;
+using Terminal.Gui.Highlighting;
+using Xunit;
+
+namespace Terminal.Gui.Editor.PerformanceTests;
+
+public class LargeDocumentLoadPerformanceTests
+{
+ ///
+ /// A 10 MiB / ~150k-line C#-highlighted document must load through
+ /// well under budget. Before max-width virtualization this took ~10 s because the editor built and
+ /// syntax-highlighted a CellVisualLine for every line just to size the horizontal scrollbar.
+ /// The model layer alone loads in ~0.2 s, so 3 s is a deliberately loose CI-jitter budget (~5×).
+ ///
+ [Fact]
+ public async Task Editor_LoadAsync_10Mb_HighlightedSource_CompletesWellUnderBudget ()
+ {
+ var sb = new StringBuilder (10 * 1024 * 1024 + 128);
+
+ while (sb.Length < 10 * 1024 * 1024)
+ {
+ sb.Append (" private const int Id = 12345; // a representative C# source line\n");
+ }
+
+ var bytes = Encoding.UTF8.GetBytes (sb.ToString ());
+
+ Editor editor = new ()
+ {
+ HighlightingDefinition = HighlightingManager.Instance.GetDefinitionByExtension (".cs")
+ };
+
+ await using MemoryStream stream = new (bytes);
+
+ Stopwatch sw = Stopwatch.StartNew ();
+ await editor.LoadAsync (stream, cancellationToken: TestContext.Current.CancellationToken);
+ sw.Stop ();
+
+ Assert.Equal (bytes.Length, editor.Document!.TextLength);
+ Assert.True (
+ sw.ElapsedMilliseconds < 3000,
+ $"Editor.LoadAsync of {bytes.Length:N0} bytes took {sw.ElapsedMilliseconds} ms — "
+ + "expected < 3000 ms (was ~10 s before max-width virtualization).");
+ }
+}
diff --git a/tests/Terminal.Gui.Editor.PerformanceTests/StreamingLoadPerformanceTests.cs b/tests/Terminal.Gui.Editor.PerformanceTests/StreamingLoadPerformanceTests.cs
new file mode 100644
index 0000000..4f91af7
--- /dev/null
+++ b/tests/Terminal.Gui.Editor.PerformanceTests/StreamingLoadPerformanceTests.cs
@@ -0,0 +1,36 @@
+using System.Diagnostics;
+using System.Text;
+using Terminal.Gui.Document;
+using Xunit;
+
+namespace Terminal.Gui.Editor.PerformanceTests;
+
+public class StreamingLoadPerformanceTests
+{
+ private static readonly TimeSpan InitialProgressBudget = TimeSpan.FromMilliseconds (500);
+
+ [Fact]
+ public async Task StreamingLoad_10Mb_ReportsInitialProgressWithinBudget ()
+ {
+ var bytes = Encoding.UTF8.GetBytes (new string ('x', 10 * 1024 * 1024));
+ await using MemoryStream stream = new (bytes);
+ TaskCompletionSource firstProgress = new ();
+ Progress progress = new (_ => firstProgress.TrySetResult ());
+
+ Stopwatch sw = Stopwatch.StartNew ();
+ Task loadTask = TextDocument.LoadAsync (
+ stream,
+ progress: progress,
+ cancellationToken: TestContext.Current.CancellationToken);
+ Task completed = await Task.WhenAny (
+ firstProgress.Task,
+ Task.Delay (InitialProgressBudget, TestContext.Current.CancellationToken));
+ sw.Stop ();
+
+ Assert.Same (firstProgress.Task, completed);
+ Assert.True (sw.Elapsed < InitialProgressBudget,
+ $"Initial streaming load progress took {sw.ElapsedMilliseconds}ms — expected < {InitialProgressBudget.TotalMilliseconds:N0}ms.");
+
+ _ = await loadTask;
+ }
+}
diff --git a/tests/Terminal.Gui.Editor.Tests/MaxWidthEstimationTests.cs b/tests/Terminal.Gui.Editor.Tests/MaxWidthEstimationTests.cs
new file mode 100644
index 0000000..4ae2ca3
--- /dev/null
+++ b/tests/Terminal.Gui.Editor.Tests/MaxWidthEstimationTests.cs
@@ -0,0 +1,48 @@
+// Claude - claude-opus-4-7
+
+using System.Drawing;
+using System.Text;
+using Terminal.Gui.Document;
+using Xunit;
+
+namespace Terminal.Gui.Editor.Tests;
+
+///
+/// The horizontal content extent is computed exactly (building a CellVisualLine per line)
+/// for normal-size documents, but estimated from character length for large ones — building +
+/// highlighting every line on load is what made a 10 MiB open take ~10 s. These pin both branches
+/// and the threshold so the fast path can't silently change small-document behavior.
+///
+public class MaxWidthEstimationTests
+{
+ // A single tab-only line: char length 1, but exact visual width expands to a tab stop (> 1).
+ [Fact]
+ public void SmallDocument_UsesExactTabExpandedWidth ()
+ {
+ Editor editor = new () { Document = new TextDocument ("\t\t\tx") };
+
+ // Exact path: 3 tabs expand to tab stops, so the extent is far wider than the 4-char length.
+ Assert.True (
+ editor.GetContentSize ().Width > 4 + 1,
+ $"Small-doc width {editor.GetContentSize ().Width} should be tab-expanded (exact), not the "
+ + "char-length estimate (5).");
+ }
+
+ [Fact]
+ public void LargeDocument_UsesCharLengthEstimate_NotTabExpanded ()
+ {
+ // > 256 KiB of identical tab-heavy lines. Each line is "\t\t\tx" (char length 4); the exact
+ // tab-expanded visual width would be much larger. The estimate path must report char length.
+ var sb = new StringBuilder (400 * 1024);
+
+ while (sb.Length < 300 * 1024)
+ {
+ sb.Append ("\t\t\tx\n");
+ }
+
+ Editor editor = new () { Document = new TextDocument (sb.ToString ()) };
+
+ // Estimate path: width == longest line's char length (4) + 1 (caret past EOL), NOT tab-expanded.
+ Assert.Equal (4 + 1, editor.GetContentSize ().Width);
+ }
+}
diff --git a/tests/Terminal.Gui.Editor.Tests/TextDocumentStreamingTests.cs b/tests/Terminal.Gui.Editor.Tests/TextDocumentStreamingTests.cs
new file mode 100644
index 0000000..32f0dd8
--- /dev/null
+++ b/tests/Terminal.Gui.Editor.Tests/TextDocumentStreamingTests.cs
@@ -0,0 +1,113 @@
+// CoPilot - gpt-5.4
+
+using System.Runtime.CompilerServices;
+using System.Text;
+using Terminal.Gui.Document;
+using Xunit;
+
+namespace Terminal.Gui.Editor.Tests;
+
+public class TextDocumentStreamingTests
+{
+ [Fact]
+ public async Task LoadAsync_SaveAsync_RoundTrips_MixedLineEndings_AndBom ()
+ {
+ UTF8Encoding encoding = new (true);
+ var text = "one\r\ntwo\nthree\rfour";
+ var bytes = encoding.GetPreamble ().Concat (encoding.GetBytes (text)).ToArray ();
+
+ await using MemoryStream input = new (bytes);
+ TextDocument document = await TextDocument.LoadAsync (
+ input,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ await using MemoryStream output = new ();
+ await document.SaveAsync (output, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal (text, document.Text);
+ Assert.Equal (bytes, output.ToArray ());
+ }
+
+ [Fact]
+ public async Task LoadAsync_Reports_Multiple_Progress_Updates_For_Large_Stream ()
+ {
+ var text = new string ('x', 100_000);
+ await using MemoryStream input = new (Encoding.UTF8.GetBytes (text));
+ List reports = [];
+ CapturingProgress progress = new (reports);
+
+ TextDocument document = await TextDocument.LoadAsync (
+ input,
+ progress: progress,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal (text.Length, document.TextLength);
+ Assert.True (reports.Count > 1);
+ Assert.Equal (text.Length, reports[^1].CharactersProcessed);
+ }
+
+ [Fact]
+ public async Task LoadAsync_Observes_Cancellation ()
+ {
+ await using MemoryStream input = new (Encoding.UTF8.GetBytes ("abc"));
+ using CancellationTokenSource cts = new ();
+ await cts.CancelAsync ();
+
+ await Assert.ThrowsAsync (() =>
+ TextDocument.LoadAsync (input, cancellationToken: cts.Token));
+ }
+
+ [Fact]
+ public async Task Editor_LoadAsync_And_SaveAsync_Delegate_To_Document ()
+ {
+ Editor editor = new ();
+ await using MemoryStream input = new (Encoding.UTF8.GetBytes ("alpha\r\nbeta"));
+
+ await editor.LoadAsync (input, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal ("alpha\r\nbeta", editor.Document!.Text);
+
+ await using MemoryStream output = new ();
+ await editor.SaveAsync (output, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal ("alpha\r\nbeta", Encoding.UTF8.GetString (output.ToArray ()));
+ }
+
+ [Fact]
+ public void SetOwnerThread_Documentation_Describes_NullOwner_FirstAccessClaim ()
+ {
+ string source = File.ReadAllText (LocateSource ("Document/TextDocument.cs"));
+
+ Assert.Contains ("first access", source, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string LocateSource (string relativePath, [CallerFilePath] string testFilePath = "")
+ {
+ var testDirectory = Path.GetDirectoryName (testFilePath)
+ ?? throw new InvalidOperationException ("Caller file path was not provided.");
+ var candidate = Path.GetFullPath (
+ Path.Combine (testDirectory, "..", "..", "src", "Terminal.Gui.Editor", relativePath));
+
+ if (File.Exists (candidate))
+ {
+ return candidate;
+ }
+
+ throw new FileNotFoundException ($"Could not locate {relativePath}.");
+ }
+
+ private sealed class CapturingProgress : IProgress
+ {
+ private readonly List _reports;
+
+ public CapturingProgress (List reports)
+ {
+ _reports = reports;
+ }
+
+ public void Report (TextDocumentProgress value)
+ {
+ _reports.Add (value);
+ }
+ }
+}
diff --git a/third_party/AvaloniaEdit/UPSTREAM.md b/third_party/AvaloniaEdit/UPSTREAM.md
index fe4927a..18c9826 100644
--- a/third_party/AvaloniaEdit/UPSTREAM.md
+++ b/third_party/AvaloniaEdit/UPSTREAM.md
@@ -81,6 +81,7 @@ Each lifted file carries `// Adapted for Terminal.Gui from AvaloniaEdit d7a6b63`
| All `Indentation/*.cs` | `namespace AvaloniaEdit.Indentation` → `namespace Terminal.Gui.Text.Indentation`; `using AvaloniaEdit.Document` → `using Terminal.Gui.Document`. |
| `Indentation/DefaultIndentationStrategy.cs` | Replaced `ArgumentNullException` throws with `ArgumentNullException.ThrowIfNull` (modern pattern). Replaced `var previousLine = line.PreviousLine;` with `DocumentLine? previousLine = line.PreviousLine;` (house style: explicit type for non-built-in). Null-check replaced with pattern match (`is null`). |
| `Document/DocumentLineTree.cs` | Stripped `using Avalonia.Threading;` and the five `Dispatcher.UIThread.VerifyAccess()` call sites (commented out with rationale). The document is no longer thread-affined — that's a UI concern, owned by `Terminal.Gui.Editor`. |
+| `Document/TextDocument.cs` | **Fork addition (file-io, DEC-009).** Added streaming `LoadAsync(Stream, ...)`, `SaveAsync(Stream, ...)`, and `Encoding` metadata so the rope-backed document can load/save large files without materializing the whole file as one `string`. `VerifyAccess()` now lazily claims a document whose owner was deliberately released after async load/save handoff. |
| `Document/TextSegmentCollection.cs` | Same `Avalonia.Threading` strip + one `VerifyAccess()` site stripped. |
| `Search/ISearchStrategy.cs` | Namespace transform only. No Avalonia references upstream. |
| `Search/RegexSearchStrategy.cs` | Namespace transform; `using AvaloniaEdit.Document` → `using Terminal.Gui.Document`. No Avalonia references upstream. Contains both `RegexSearchStrategy` and `SearchResult` (kept as a single file matching upstream layout). Added `#nullable disable` directive after the "Adapted for" line — upstream predates nullable reference types (`IEquatable.Equals` override, `SearchResult.Data` auto-property, and `FindAll().FirstOrDefault()` all trip CS warnings under nullable enable; suppressing per-file matches the fork policy of "minimal targeted edits to lifted source"). **Correctness deviation**: `Equals(ISearchStrategy)` now includes `_matchWholeWords` in the comparison. Upstream omits it, so two strategies that differ only by whole-word matching compare equal — breaks consumer caching/dedup. Surfaced in Copilot review of PR #76. **Perf deviation** (gui-cs/Text#82): `FindAll` now drives the regex engine via `Regex.Match(text, startat)` + `NextMatch()` from `offset` instead of `_searchPattern.Matches(text)` over the whole document followed by post-filtering. Upstream re-scans the prefix `[0, offset)` on every call — wasted work for incremental advancing search (one FindNext per F3 keystroke). The .NET regex engine preserves `RegexOptions.Multiline` `^` / `$` semantics across `startat` (anchoring at the start position only when it is 0 or follows a newline). Worth mirroring upstream at AvaloniaEdit. |