diff --git a/AGENTS.md b/AGENTS.md index fe44e6f..8022561 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,7 @@ What follows is how TuiCode implements it today. - Cut and paste (#127) is a second way to move: `Ctrl+X` marks `FileExplorerView.PendingCut` (drawn faint in `DrawLine`, before the icon handler so the icon dims too) and `Ctrl+V` moves it through the same `Workbench.Move` as `F2`, targeting the folder `NewEntryTarget` would pick. Both are `Explorer`-scoped, so the editor keeps text cut/paste; `CancelCut` (Esc) is enabled only while a cut is pending, so Esc still focuses the editor otherwise. `Delete`/`Move` clear a cut at or under what they touch. Windows Terminal binds `Ctrl+V` to its own paste, so the `xf`/`pf` mnemonics are the fallback. - `FileSystemTreeBuilder` hands out fresh `IFileSystemInfo`s on every read and TG's `Branch.Refresh` matches children by reference, so a plain `RefreshObject` collapses every folder under the refreshed node. `FileExplorerView.RefreshKeepingExpansion` records expanded paths and re-expands them. - **Refresh explorer** (`re`, Global, no default key, #332) is `FileExplorerView.Refresh`: `RefreshKeepingExpansion` from the root, so only expanded folders are re-read, then the selection is put back by path. A selection that's gone moves to the neighbour of the first vanished entry on its path, found in the tree's pre-refresh listing since the disk no longer has it. +- **Expanded folders refresh themselves (#334).** `FolderWatcher` (`Workbench/Files/`) keeps one non-recursive watcher per entry in `FileExplorerView.ExpandedFolders`, capped at 64 (most recently expanded win), debounced 250 ms and flushed through `WorkbenchHost.ScheduleFlush` into `FileExplorerView.Refresh(folders)`. TG's `TreeView` raises nothing on expand/collapse, so the view re-derives `ExpandedFolders` after its own refreshes and on every draw. An automatic refresh only scrolls if the selection was already in view. In host tests the Explorer and `DiskWatcher` can both watch a folder, so raise events through `FakeWatcherFactory.At`. ## Editor gutter (#23) diff --git a/src/TuiCode.Explorer/FileExplorerView.cs b/src/TuiCode.Explorer/FileExplorerView.cs index b9e78be..433e015 100644 --- a/src/TuiCode.Explorer/FileExplorerView.cs +++ b/src/TuiCode.Explorer/FileExplorerView.cs @@ -8,12 +8,18 @@ namespace TuiCode.Explorer; public sealed class FileExplorerView : TreeView { private readonly HashSet _changedOnDisk = new(StringComparer.Ordinal); + private readonly List _expandedFolders = []; public event EventHandler? FileActivated; /// Raised with the item whose cut mark was just cleared, whether pasted, cancelled, deleted or renamed. public event EventHandler? CutCleared; + public event EventHandler? ExpandedFoldersChanged; + + /// The full paths of the expanded folders, the root included, oldest expanded first. + public IReadOnlyList ExpandedFolders => _expandedFolders; + /// The directory the tree is currently rooted at, or null before the first . public IDirectoryInfo? Root { get; private set; } @@ -68,6 +74,7 @@ public void Open(IDirectoryInfo root) ClearObjects(); AddObject(root); Expand(root); + NoteExpansion(); } public void ActivateSelected() @@ -222,6 +229,30 @@ public void Refresh() EnsureVisible(SelectedObject); } + /// + /// Re-read those of that are expanded, as does, but only scroll to + /// keep the selection in view if it already was. + /// + public void Refresh(IEnumerable folders) + { + if (Root is not { } root) return; + var selected = SelectedObject; + var reselect = selected is not null ? SurvivingSelection(root, selected.FullName) : null; + var inView = selected is not null && GetObjectRow(selected) is { } row && row >= 0 && row < Viewport.Height; + + var refreshed = new List(); + foreach (var folder in folders.OrderBy(f => f.Length)) + { + if (refreshed.Any(done => FilePaths.IsSameOrUnder(folder, done))) continue; + if (Find(folder) is not { } node || !IsExpanded(node)) continue; + RefreshKeepingExpansion(node); + refreshed.Add(folder); + } + if (refreshed.Count == 0 || reselect is null) return; + SelectedObject = Find(reselect) ?? root; + if (inView) EnsureVisible(SelectedObject); + } + // The tree still lists what was there before the refresh, so the neighbours of the first vanished entry on the path come from it. private string SurvivingSelection(IDirectoryInfo root, string selected) { @@ -340,6 +371,7 @@ private IDirectoryInfo RequireRoot() => } SelectedObject = current; + NoteExpansion(); return current; } @@ -371,6 +403,31 @@ private void RefreshKeepingExpansion(IFileSystemInfo node) foreach (var path in expanded) if (Find(path) is { } again) Expand(again); + NoteExpansion(); + } + + // TG raises nothing when a folder is expanded or collapsed, but it always redraws. + protected override bool OnDrawingContent(DrawContext? context) + { + var handled = base.OnDrawingContent(context); + NoteExpansion(); + return handled; + } + + private void NoteExpansion() + { + var now = new List(); + if (Root is { } root && IsExpanded(root)) + { + now.Add(root.FullName); + CollectExpanded(root, now); + } + var kept = _expandedFolders.Intersect(now, StringComparer.Ordinal).ToList(); + if (kept.Count == now.Count && kept.Count == _expandedFolders.Count) return; + kept.AddRange(now.Except(kept, StringComparer.Ordinal)); + _expandedFolders.Clear(); + _expandedFolders.AddRange(kept); + ExpandedFoldersChanged?.Invoke(this, EventArgs.Empty); } private void CollectExpanded(IFileSystemInfo node, List expanded) diff --git a/src/TuiCode.Workbench/Files/FolderWatcher.cs b/src/TuiCode.Workbench/Files/FolderWatcher.cs new file mode 100644 index 0000000..5e2d66b --- /dev/null +++ b/src/TuiCode.Workbench/Files/FolderWatcher.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.Logging; + +namespace TuiCode.Workbench.Files; + +/// +/// One non-recursive watcher per expanded Explorer folder, reporting folders whose entries appeared, went or were +/// renamed (#334). Past only the most recently expanded folders are watched. +/// +internal sealed class FolderWatcher : IDisposable +{ + internal const int Cap = 64; + + // A git switch is a storm of events; one refresh per folder once it's over. + private static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(250); + + private readonly IFileSystem _fileSystem; + private readonly Action _schedule; + private readonly ILogger _logger; + private readonly Dictionary _watchers = new(StringComparer.Ordinal); + private readonly HashSet _pending = new(StringComparer.Ordinal); + private readonly Lock _gate = new(); + private bool _armed; + private bool _disposed; + + /// The folders whose entries changed, once the events have settled. Raised on the UI thread. + public event EventHandler>? Changed; + + /// Runs the flush on the UI thread once the debounce window has passed. + public FolderWatcher(IFileSystem fileSystem, Action schedule, ILogger logger) + { + _fileSystem = fileSystem; + _schedule = schedule; + _logger = logger; + } + + internal IReadOnlyCollection Watched + { + get { lock (_gate) return _watchers.Keys.ToList(); } + } + + /// Watch , oldest expanded first, adding and dropping watchers to match. + public void Follow(IReadOnlyList folders) + { + if (_disposed) return; + var wanted = folders.Skip(Math.Max(0, folders.Count - Cap)).ToHashSet(StringComparer.Ordinal); + List missing; + lock (_gate) + { + foreach (var gone in _watchers.Keys.Where(f => !wanted.Contains(f)).ToList()) Drop(gone); + missing = wanted.Where(f => !_watchers.ContainsKey(f)).ToList(); + } + foreach (var folder in missing) Add(folder); + } + + private void Add(string folder) + { + IFileSystemWatcher? watcher = null; + try + { + watcher = _fileSystem.FileSystemWatcher.New(folder); + watcher.IncludeSubdirectories = false; + watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName; + watcher.Created += (_, _) => Note(folder); + watcher.Deleted += (_, _) => Note(folder); + watcher.Renamed += (_, _) => Note(folder); + watcher.Error += (_, e) => OnError(folder, e.GetException()); + watcher.EnableRaisingEvents = true; + lock (_gate) _watchers[folder] = watcher; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or NotImplementedException) + { + watcher?.Dispose(); + _logger.LogWarning(e, "Not watching {Folder} for new, removed or renamed entries", folder); + } + } + + private void OnError(string folder, Exception exception) + { + _logger.LogWarning(exception, "Stopped watching {Folder} for new, removed or renamed entries", folder); + lock (_gate) Drop(folder); + } + + // Under _gate. + private void Drop(string folder) + { + if (_watchers.Remove(folder, out var watcher)) watcher.Dispose(); + } + + private void Note(string folder) + { + if (_disposed) return; + lock (_gate) + { + _pending.Add(folder); + if (_armed) return; + _armed = true; + } + _schedule(Debounce, Flush); + } + + private void Flush() + { + string[] changed; + lock (_gate) + { + _armed = false; + changed = _pending.Where(_watchers.ContainsKey).ToArray(); + _pending.Clear(); + } + if (!_disposed && changed.Length > 0) Changed?.Invoke(this, changed); + } + + public void Dispose() + { + _disposed = true; + lock (_gate) + { + foreach (var folder in _watchers.Keys.ToList()) Drop(folder); + _pending.Clear(); + } + } +} diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index b25ec6f..bf4cd8a 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -60,6 +60,7 @@ public sealed class WorkbenchHost : IDisposable private readonly FindController _find; private readonly FocusService _focus; private readonly DiskChanges _diskChanges; + private readonly FolderWatcher _folderWatcher; private readonly CursorLocationHistory _history = new(); // Set while we drive the cursor ourselves (Back/Forward, Go-to-line) so those moves // don't get re-recorded as fresh jumps. @@ -163,12 +164,19 @@ public WorkbenchHost( ApplyIconStyle(); if (_icons is not null) _icons.Changed += (_, _) => ApplyIconStyle(); + fileSystem ??= new FileSystem(); // Tell a tab its file changed the moment it happens, rather than at the save it would lose (#268). _diskChanges = new DiskChanges( _workbench.Editor.Group, _workbench.Sidebar.Explorer, _workbench.StatusBar.SetMessage, - new DiskWatcher(fileSystem ?? new FileSystem(), ScheduleFlush, _logger)); + new DiskWatcher(fileSystem, ScheduleFlush, _logger)); + + var explorer = _workbench.Sidebar.Explorer; + _folderWatcher = new FolderWatcher(fileSystem, ScheduleFlush, _logger); + explorer.ExpandedFoldersChanged += (_, _) => _folderWatcher.Follow(explorer.ExpandedFolders); + _folderWatcher.Changed += (_, folders) => explorer.Refresh(folders); + _folderWatcher.Follow(explorer.ExpandedFolders); _app.Keyboard.KeyDown += OnAppKeyDown; _app.Mouse.MouseEvent += OnAppMouseEvent; @@ -2534,6 +2542,7 @@ public void Dispose() _workbench.Editor.Group.CursorMoved -= OnEditorCursorMoved; _workbench.Editor.Group.ActiveTabChanged -= OnActiveTabChanged; _diskChanges.Dispose(); + _folderWatcher.Dispose(); _find.Dispose(); _terminalCursors.Dispose(); _workbench.Dispose(); diff --git a/tests/TuiCode.Tests/DiskDeleteHostTests.cs b/tests/TuiCode.Tests/DiskDeleteHostTests.cs index ec363e0..6dab6aa 100644 --- a/tests/TuiCode.Tests/DiskDeleteHostTests.cs +++ b/tests/TuiCode.Tests/DiskDeleteHostTests.cs @@ -65,7 +65,7 @@ private void Delete(string path) { var full = _fs.Path.GetFullPath(path); _fs.File.Delete(full); - _fs.Watchers.For(_fs.Path.GetDirectoryName(full)!).Raise(WatcherChangeTypes.Deleted, full); + foreach (var watcher in _fs.Watchers.At(_fs.Path.GetDirectoryName(full)!)) watcher.Raise(WatcherChangeTypes.Deleted, full); } private Workbench.Workbench BuildWorkbench() diff --git a/tests/TuiCode.Tests/DiskReloadHostTests.cs b/tests/TuiCode.Tests/DiskReloadHostTests.cs index 339502e..89c8d7c 100644 --- a/tests/TuiCode.Tests/DiskReloadHostTests.cs +++ b/tests/TuiCode.Tests/DiskReloadHostTests.cs @@ -61,7 +61,7 @@ private void Change(string path, string content) { var full = _fs.Path.GetFullPath(path); _fs.File.WriteAllText(full, content); - _fs.Watchers.For(_fs.Path.GetDirectoryName(full)!).RaiseChanged(full); + foreach (var watcher in _fs.Watchers.At(_fs.Path.GetDirectoryName(full)!)) watcher.RaiseChanged(full); } private Workbench.Workbench BuildWorkbench() diff --git a/tests/TuiCode.Tests/ExplorerWatchHostTests.cs b/tests/TuiCode.Tests/ExplorerWatchHostTests.cs new file mode 100644 index 0000000..7c2310e --- /dev/null +++ b/tests/TuiCode.Tests/ExplorerWatchHostTests.cs @@ -0,0 +1,66 @@ +using Terminal.Gui.Drivers; +using TuiCode.Explorer; +using TuiCode.Workbench; +using TuiCode.Workbench.Parts; +using TuiCode.Workbench.Services; + +namespace TuiCode.Tests; + +// The Explorer notices entries appearing and going in an expanded folder by itself (#334). +// Boots a TG Application — serialised (#77). +public class ExplorerWatchHostTests : StaticConfigurationTest +{ + private readonly WatchableFileSystem _fs = new(); + + [Fact] + public async Task A_file_created_outside_appears_in_the_tree_without_a_keypress() + { + using var workbench = BuildWorkbench(); + using var host = BuildHost(workbench); + var explorer = workbench.Sidebar.Explorer; + + await HostSteps.Run(host, + () => Create("/work/b.txt"), + () => Names(explorer).Contains("b.txt")); + + Assert.Equal(["a.txt", "b.txt"], Names(explorer)); + } + + [Fact] + public void Closing_the_host_stops_watching_the_explorer() + { + using var workbench = BuildWorkbench(); + var host = BuildHost(workbench); + Assert.NotEmpty(_fs.Watchers.Live); + + host.Dispose(); + + Assert.Empty(_fs.Watchers.Live); + } + + private void Create(string path) + { + var full = _fs.Path.GetFullPath(path); + _fs.AddFile(full, new MockFileData("")); + foreach (var watcher in _fs.Watchers.At(_fs.Path.GetDirectoryName(full)!)) watcher.Raise(WatcherChangeTypes.Created, full); + } + + private static string[] Names(FileExplorerView explorer) => + [.. explorer.GetChildren(explorer.Root!).Select(c => c.Name)]; + + private Workbench.Workbench BuildWorkbench() + { + var workbench = new Workbench.Workbench(new SidebarPart(new FileExplorerView()), new EditorPart(), new StatusBarPart()); + _fs.AddDirectory("/work"); + _fs.AddFile("/work/a.txt", new MockFileData("")); + workbench.Sidebar.Explorer.Open(_fs.DirectoryInfo.New("/work")); + return workbench; + } + + private WorkbenchHost BuildHost(Workbench.Workbench workbench) + { + var commands = new CommandService(); + return new WorkbenchHost(workbench, commands, new KeybindingService(commands), new InputScopeStack(), + new InMemorySettingsService(), driverName: DriverRegistry.Names.ANSI, fileSystem: _fs); + } +} diff --git a/tests/TuiCode.Tests/FakeWatchers.cs b/tests/TuiCode.Tests/FakeWatchers.cs index 77a6b7e..e7cb258 100644 --- a/tests/TuiCode.Tests/FakeWatchers.cs +++ b/tests/TuiCode.Tests/FakeWatchers.cs @@ -29,8 +29,11 @@ internal sealed class FakeWatcherFactory(IFileSystem fileSystem) : IFileSystemWa public IEnumerable Live => All.Where(w => w is { Disposed: false, EnableRaisingEvents: true }); - public FakeFileSystemWatcher For(string directory) => - Live.Single(w => string.Equals(w.Path, directory, StringComparison.Ordinal)); + public FakeFileSystemWatcher For(string directory) => At(directory).Single(); + + /// Every live watcher on : in the host the Explorer and the open tabs each watch their own. + public IEnumerable At(string directory) => + Live.Where(w => string.Equals(w.Path, directory, StringComparison.Ordinal)).ToList(); public IFileSystemWatcher New() => throw new NotSupportedException(); diff --git a/tests/TuiCode.Tests/FileExplorerRefreshTests.cs b/tests/TuiCode.Tests/FileExplorerRefreshTests.cs index 6c40825..daa23c1 100644 --- a/tests/TuiCode.Tests/FileExplorerRefreshTests.cs +++ b/tests/TuiCode.Tests/FileExplorerRefreshTests.cs @@ -215,6 +215,81 @@ public void Refresh_scrolls_only_as_far_as_keeping_the_selection_in_view_needs() Assert.Equal(9, explorer.ScrollOffsetVertical); } + [Fact] + public void ExpandedFolders_lists_the_root_then_folders_in_the_order_they_were_expanded() + { + using var explorer = Open("/work/b/x.cs", "/work/a/y.cs"); + var raised = 0; + explorer.ExpandedFoldersChanged += (_, _) => raised++; + + explorer.Expand(Node(explorer, "b")); + explorer.Refresh(); + explorer.Expand(Node(explorer, "a")); + explorer.Refresh(); + + Assert.Equal([".", "b", "a"], explorer.ExpandedFolders.Select(Full).Select(Relative)); + Assert.Equal(2, raised); + } + + [Fact] + public void ExpandedFolders_drops_a_collapsed_folder_and_everything_under_it() + { + using var explorer = Open("/work/a/inner/x.cs", "/work/b.cs"); + Node(explorer, "a/inner/x.cs"); + explorer.Refresh(); + + explorer.Collapse(Node(explorer, "a")); + explorer.Refresh(); + + Assert.Equal(["."], explorer.ExpandedFolders.Select(Full).Select(Relative)); + } + + [Fact] + public void Refresh_of_some_folders_reads_only_those_that_are_expanded() + { + using var explorer = Open("/work/open/a.cs", "/work/other/b.cs", "/work/shut/c.cs"); + Node(explorer, "open/a.cs"); + Node(explorer, "other/b.cs"); + var builder = new CountingTreeBuilder(explorer.TreeBuilder!); + explorer.TreeBuilder = builder; + + _fs.AddFile("/work/open/d.cs", new MockFileData("")); + explorer.Refresh([_fs.Path.GetFullPath("/work/open"), _fs.Path.GetFullPath("/work/shut")]); + + Assert.Equal([_fs.Path.GetFullPath("/work/open")], builder.Read); + Assert.Equal(["a.cs", "d.cs"], Names(explorer, "open")); + } + + [Fact] + public void Refresh_of_some_folders_moves_a_deleted_selection_to_its_neighbour() + { + using var explorer = Open("/work/src/b.cs", "/work/src/c.cs"); + explorer.SelectedObject = Node(explorer, "src/b.cs"); + + _fs.File.Delete("/work/src/b.cs"); + explorer.Refresh([_fs.Path.GetFullPath("/work/src")]); + + Assert.Equal("src/c.cs", Relative(explorer.SelectedObject)); + } + + [Fact] + public void Refresh_of_some_folders_leaves_the_scroll_alone_when_the_selection_is_out_of_view() + { + using var explorer = Open([.. Enumerable.Range(0, 20).Select(i => $"/work/f{i:00}.txt")]); + explorer.Frame = new Rectangle(0, 0, 20, 5); + explorer.SetContentSize(new Size(20, 21)); + explorer.SelectedObject = Node(explorer, "f18.txt"); + explorer.ScrollOffsetVertical = 0; + + _fs.AddFile("/work/f00a.txt", new MockFileData("")); + explorer.Refresh([_fs.Path.GetFullPath("/work")]); + + Assert.Equal(0, explorer.ScrollOffsetVertical); + Assert.Equal("f18.txt", Relative(explorer.SelectedObject)); + } + + private IFileSystemInfo Full(string path) => _fs.DirectoryInfo.New(path); + private sealed class CountingTreeBuilder(ITreeBuilder inner) : ITreeBuilder { public List Read { get; } = []; diff --git a/tests/TuiCode.Tests/FolderWatcherTests.cs b/tests/TuiCode.Tests/FolderWatcherTests.cs new file mode 100644 index 0000000..9a0c1a6 --- /dev/null +++ b/tests/TuiCode.Tests/FolderWatcherTests.cs @@ -0,0 +1,158 @@ +using Microsoft.Extensions.Logging; +using TuiCode.Workbench.Files; + +namespace TuiCode.Tests; + +// One non-recursive watcher per expanded folder, reporting entries that appear, go or are renamed (#334). +public class FolderWatcherTests +{ + private readonly WatchableFileSystem _fs = new(); + private readonly ListLogger _logger = new(); + private readonly List _flushes = []; + private readonly List _reported = []; + + private readonly string _work; + private readonly string _src; + + public FolderWatcherTests() + { + _work = Path("/work"); + _src = Path("/work/src"); + } + + [Fact] + public void Each_expanded_folder_gets_one_non_recursive_watcher_for_its_entries() + { + using var watcher = Watch(_work, _src); + + Assert.Equal([_work, _src], _fs.Watchers.Live.Select(w => w.Path).Order()); + Assert.All(_fs.Watchers.Live, w => Assert.False(w.IncludeSubdirectories)); + Assert.All(_fs.Watchers.Live, w => Assert.Equal(NotifyFilters.FileName | NotifyFilters.DirectoryName, w.NotifyFilter)); + } + + [Theory] + [InlineData(WatcherChangeTypes.Created)] + [InlineData(WatcherChangeTypes.Deleted)] + [InlineData(WatcherChangeTypes.Renamed)] + public void An_entry_appearing_going_or_renamed_reports_its_folder_once_the_events_settle(WatcherChangeTypes change) + { + using var watcher = Watch(_work); + + _fs.Watchers.For(_work).Raise(change, Path("/work/a.txt")); + Assert.Empty(_reported); + + Flush(); + Assert.Equal([_work], _reported); + } + + [Fact] + public void A_burst_of_events_in_one_folder_produces_one_refresh() + { + using var watcher = Watch(_work); + + for (var i = 0; i < 20; i++) + _fs.Watchers.For(_work).Raise(WatcherChangeTypes.Created, Path($"/work/{i}.txt")); + + Assert.Single(_flushes); + Flush(); + Assert.Equal([_work], _reported); + } + + [Fact] + public void Events_in_different_folders_refresh_each() + { + using var watcher = Watch(_work, _src); + + _fs.Watchers.For(_work).Raise(WatcherChangeTypes.Deleted, Path("/work/a.txt")); + _fs.Watchers.For(_src).Raise(WatcherChangeTypes.Created, Path("/work/src/b.cs")); + Flush(); + + Assert.Equal([_work, _src], _reported.Order()); + } + + [Fact] + public void Collapsing_a_folder_disposes_its_watcher() + { + using var watcher = Watch(_work, _src); + var src = _fs.Watchers.For(_src); + + watcher.Follow([_work]); + + Assert.True(src.Disposed); + Assert.Equal([_work], watcher.Watched); + } + + [Fact] + public void An_event_from_a_folder_collapsed_before_the_flush_is_dropped() + { + using var watcher = Watch(_work, _src); + + _fs.Watchers.For(_src).Raise(WatcherChangeTypes.Created, Path("/work/src/b.cs")); + watcher.Follow([_work]); + Flush(); + + Assert.Empty(_reported); + } + + [Fact] + public void Past_the_cap_the_most_recently_expanded_folders_are_watched() + { + var folders = Enumerable.Range(0, FolderWatcher.Cap + 6).Select(i => Path($"/work/f{i:00}")).ToList(); + + using var watcher = Watch([.. folders]); + + Assert.Equal(folders.Skip(6).Order(), watcher.Watched.Order()); + Assert.Equal(FolderWatcher.Cap, _fs.Watchers.Live.Count()); + } + + [Fact] + public void A_watcher_that_fails_to_start_is_logged_and_skipped() + { + _fs.Watchers.FailFor.Add(_src); + + using var watcher = Watch(_work, _src); + + Assert.Equal([_work], watcher.Watched); + Assert.Contains(_logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains(_src)); + } + + [Fact] + public void A_watcher_that_errors_is_logged_and_dropped() + { + using var watcher = Watch(_work, _src); + var src = _fs.Watchers.For(_src); + + src.RaiseError(new IOException("gone")); + + Assert.True(src.Disposed); + Assert.Equal([_work], watcher.Watched); + Assert.Contains(_logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains(_src)); + } + + [Fact] + public void Disposing_stops_watching_everything() + { + var watcher = Watch(_work, _src); + + watcher.Dispose(); + + Assert.Empty(watcher.Watched); + Assert.All(_fs.Watchers.All, w => Assert.True(w.Disposed)); + } + + private FolderWatcher Watch(params string[] folders) + { + var watcher = new FolderWatcher(_fs, (_, flush) => _flushes.Add(flush), _logger); + watcher.Changed += (_, changed) => _reported.AddRange(changed); + watcher.Follow(folders); + return watcher; + } + + private void Flush() + { + foreach (var flush in _flushes.ToList()) flush(); + _flushes.Clear(); + } + + private string Path(string path) => _fs.Path.GetFullPath(path); +}