Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
57 changes: 57 additions & 0 deletions src/TuiCode.Explorer/FileExplorerView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ namespace TuiCode.Explorer;
public sealed class FileExplorerView : TreeView<IFileSystemInfo>
{
private readonly HashSet<string> _changedOnDisk = new(StringComparer.Ordinal);
private readonly List<string> _expandedFolders = [];

public event EventHandler<IFileInfo>? FileActivated;

/// <summary>Raised with the item whose cut mark was just cleared, whether pasted, cancelled, deleted or renamed.</summary>
public event EventHandler<IFileSystemInfo>? CutCleared;

public event EventHandler? ExpandedFoldersChanged;

/// <summary>The full paths of the expanded folders, the root included, oldest expanded first.</summary>
public IReadOnlyList<string> ExpandedFolders => _expandedFolders;

/// <summary>The directory the tree is currently rooted at, or null before the first <see cref="Open"/>.</summary>
public IDirectoryInfo? Root { get; private set; }

Expand Down Expand Up @@ -68,6 +74,7 @@ public void Open(IDirectoryInfo root)
ClearObjects();
AddObject(root);
Expand(root);
NoteExpansion();
}

public void ActivateSelected()
Expand Down Expand Up @@ -222,6 +229,30 @@ public void Refresh()
EnsureVisible(SelectedObject);
}

/// <summary>
/// Re-read those of <paramref name="folders"/> that are expanded, as <see cref="Refresh()"/> does, but only scroll to
/// keep the selection in view if it already was.
/// </summary>
public void Refresh(IEnumerable<string> 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<string>();
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)
{
Expand Down Expand Up @@ -340,6 +371,7 @@ private IDirectoryInfo RequireRoot() =>
}

SelectedObject = current;
NoteExpansion();
return current;
}

Expand Down Expand Up @@ -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<string>();
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<string> expanded)
Expand Down
122 changes: 122 additions & 0 deletions src/TuiCode.Workbench/Files/FolderWatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using Microsoft.Extensions.Logging;

namespace TuiCode.Workbench.Files;

/// <summary>
/// One non-recursive watcher per expanded Explorer folder, reporting folders whose entries appeared, went or were
/// renamed (#334). Past <see cref="Cap"/> only the most recently expanded folders are watched.
/// </summary>
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<TimeSpan, Action> _schedule;
private readonly ILogger _logger;
private readonly Dictionary<string, IFileSystemWatcher> _watchers = new(StringComparer.Ordinal);
private readonly HashSet<string> _pending = new(StringComparer.Ordinal);
private readonly Lock _gate = new();
private bool _armed;
private bool _disposed;

/// <summary>The folders whose entries changed, once the events have settled. Raised on the UI thread.</summary>
public event EventHandler<IReadOnlyList<string>>? Changed;

/// <param name="schedule">Runs the flush on the UI thread once the debounce window has passed.</param>
public FolderWatcher(IFileSystem fileSystem, Action<TimeSpan, Action> schedule, ILogger logger)
{
_fileSystem = fileSystem;
_schedule = schedule;
_logger = logger;
}

internal IReadOnlyCollection<string> Watched
{
get { lock (_gate) return _watchers.Keys.ToList(); }
}

/// <summary>Watch <paramref name="folders"/>, oldest expanded first, adding and dropping watchers to match.</summary>
public void Follow(IReadOnlyList<string> folders)
{
if (_disposed) return;
var wanted = folders.Skip(Math.Max(0, folders.Count - Cap)).ToHashSet(StringComparer.Ordinal);
List<string> 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();
}
}
}
11 changes: 10 additions & 1 deletion src/TuiCode.Workbench/WorkbenchHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion tests/TuiCode.Tests/DiskDeleteHostTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion tests/TuiCode.Tests/DiskReloadHostTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
66 changes: 66 additions & 0 deletions tests/TuiCode.Tests/ExplorerWatchHostTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
7 changes: 5 additions & 2 deletions tests/TuiCode.Tests/FakeWatchers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ internal sealed class FakeWatcherFactory(IFileSystem fileSystem) : IFileSystemWa

public IEnumerable<FakeFileSystemWatcher> 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();

/// <summary>Every live watcher on <paramref name="directory"/>: in the host the Explorer and the open tabs each watch their own.</summary>
public IEnumerable<FakeFileSystemWatcher> At(string directory) =>
Live.Where(w => string.Equals(w.Path, directory, StringComparison.Ordinal)).ToList();

public IFileSystemWatcher New() => throw new NotSupportedException();

Expand Down
Loading
Loading