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 @@ -125,6 +125,7 @@ What follows is how TuiCode implements it today.
- `Delete` and `F2` are `Explorer`-scoped (Delete must still delete text in the editor). `Ctrl+D` is bound there too: our iTerm2 profile sends forward-delete as `^D`, which the editor's TextView treats as delete-right. The target is the explorer selection when the explorer has focus, else the active tab's file. Launchers close — focusing the editor — before running their command, so `ActionView`/`MnemonicView` capture whether the explorer had focus when they opened (`RunLaunched`).
- 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.

## Editor gutter (#23)

Expand Down
1 change: 1 addition & 0 deletions src/TuiCode.Abstractions/CommandIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ public static class CommandIds
public const string CutFile = "explorer.action.cutFile";
public const string PasteFile = "explorer.action.pasteFile";
public const string CancelCut = "explorer.action.cancelCut";
public const string RefreshExplorer = "explorer.action.refresh";
public const string ConfirmCancel = "confirm.action.cancel";

public const string MoveLinesUp = "editor.action.moveLinesUp";
Expand Down
1 change: 1 addition & 0 deletions src/TuiCode.Abstractions/CommandMnemonics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ private static IReadOnlyDictionary<string, string> Build()
[CommandIds.CompareToRevision] = "ctr",
[CommandIds.CompareToOtherFile] = "cto",
[CommandIds.ReloadFromDisk] = "rd",
[CommandIds.RefreshExplorer] = "re",
[CommandIds.ToggleSidebar] = "ts",
[CommandIds.WidenSidebar] = "ws",
[CommandIds.NarrowSidebar] = "ns",
Expand Down
36 changes: 36 additions & 0 deletions src/TuiCode.Explorer/FileExplorerView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,42 @@ public IFileSystemInfo Move(IFileSystemInfo item, string relativePath)
?? throw new IOException($"Moved to '{shown}' but could not locate it in the tree.");
}

/// <summary>
/// Re-read every expanded folder from disk, keeping them expanded and the selection on the same path. A selection
/// that's gone moves to its neighbour, as after <see cref="Delete"/>.
/// </summary>
public void Refresh()
{
if (Root is not { } root) return;
var reselect = SelectedObject is { } selected ? SurvivingSelection(root, selected.FullName) : null;
RefreshKeepingExpansion(root);
if (reselect is null) return;
SelectedObject = Find(reselect) ?? root;
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)
{
var fs = root.FileSystem;
bool Exists(IFileSystemInfo entry) => fs.File.Exists(entry.FullName) || fs.Directory.Exists(entry.FullName);

IFileSystemInfo current = root;
foreach (var segment in Segments(root, selected))
{
var siblings = GetChildren(current).ToList();
var index = siblings.FindIndex(c => string.Equals(c.Name, segment, StringComparison.Ordinal));
if (index < 0) return current.FullName;
if (!Exists(siblings[index]))
{
var neighbour = siblings.Skip(index + 1).Concat(siblings.Take(index).Reverse()).FirstOrDefault(Exists);
return (neighbour ?? current).FullName;
}
current = siblings[index];
}
return selected;
}

internal IReadOnlyCollection<string> MarkedOnDisk => _changedOnDisk;

/// <summary>
Expand Down
8 changes: 8 additions & 0 deletions src/TuiCode.Workbench/WorkbenchHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,8 @@ private void RegisterDefaultCommands()
_commands.Register(CommandIds.CutFile, "Cut file or folder", CutEntry, CommandScope.Explorer);
_commands.Register(CommandIds.PasteFile, "Paste file or folder", PasteEntry, CommandScope.Explorer);
_commands.Register(CommandIds.CancelCut, "Cancel cut", explorer.ClearCut, CommandScope.Explorer, () => explorer.PendingCut is not null);
// No default key (#332).
_commands.Register(CommandIds.RefreshExplorer, "Refresh explorer", RefreshExplorer, CommandScope.Global, () => explorer.Root is not null);
explorer.CutCleared += (_, item) => _workbench.StatusBar.ClearMessage(CutMessage(item));
var search = _workbench.Sidebar.Search;
// Find covers the whole pane, results list included; these keys belong to the query/replace inputs only.
Expand Down Expand Up @@ -1209,6 +1211,12 @@ private void OpenRename()
ShowPathPrompt(view);
}

private void RefreshExplorer()
{
_workbench.Sidebar.Explorer.Refresh();
if (ExplorerIsContext) FocusSidebar();
}

private void CutEntry()
{
var fromExplorer = ExplorerIsContext;
Expand Down
119 changes: 119 additions & 0 deletions tests/TuiCode.Tests/ExplorerRefreshHostTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using TuiCode.Abstractions;
using TuiCode.Explorer;
using TuiCode.Workbench;
using TuiCode.Workbench.Actions;
using TuiCode.Workbench.Parts;
using TuiCode.Workbench.Services;

namespace TuiCode.Tests;

// Refresh explorer from the palette, the leader and a user's own key (#332). Boots a TG Application — serialised (#77).
public class ExplorerRefreshHostTests : StaticConfigurationTest
{
private readonly MockFileSystem _fs = new();
private readonly CommandService _commands = new();
private readonly KeybindingService _keybindings;

public ExplorerRefreshHostTests() => _keybindings = new KeybindingService(_commands);

[Fact]
public void Is_a_global_command_with_a_label_mnemonic_and_no_default_key()
{
using var workbench = BuildWorkbench();
using var _ = BuildHost(workbench);

var command = Assert.Single(_commands.Registered, c => c.Id == CommandIds.RefreshExplorer);
Assert.Equal("Refresh explorer", command.Label);
Assert.Equal(CommandScope.Global, command.Scope);
Assert.Equal("re", CommandMnemonics.For(CommandIds.RefreshExplorer));
Assert.DoesNotContain(_keybindings.Bindings, b => b.CommandId == CommandIds.RefreshExplorer);
}

[Fact]
public async Task The_palette_refreshes_the_explorer()
{
using var workbench = BuildWorkbench();
using var host = BuildHost(workbench);

await HostSteps.Run(host,
ChangeOnDisk,
() => host.App.InjectKey(Key.E.WithCtrl),
() => Palette(workbench) is not null,
() => { foreach (var c in "refresh explorer") host.App.InjectKey(new Key(c)); },
() => Palette(workbench)!.Labels.SequenceEqual(["Refresh explorer"]),
() => host.App.InjectKey(Key.Enter),
() => Palette(workbench) is null);

Assert.Equal(["b.txt"], Names(workbench));
}

[Fact]
public async Task The_leader_then_re_refreshes_the_explorer()
{
using var workbench = BuildWorkbench();
using var host = BuildHost(workbench);

await HostSteps.Run(host,
ChangeOnDisk,
() => host.App.InjectKey(Key.Space.WithCtrl),
() => host.App.InjectKey(Key.R),
() => host.App.InjectKey(Key.E),
() => Names(workbench).SequenceEqual(["b.txt"]));
}

[Fact]
public async Task A_key_bound_in_settings_refreshes_the_explorer()
{
using var workbench = BuildWorkbench();
var settings = new InMemorySettingsService();
settings.SetKeybindingOverrides([new KeybindingOverride(TestKeys.Chord("F5"), CommandIds.RefreshExplorer)]);
using var host = BuildHost(workbench, settings);

await HostSteps.Run(host,
ChangeOnDisk,
() => host.App.InjectKey(Key.F5),
() => Names(workbench).SequenceEqual(["b.txt"]));
}

[Fact]
public async Task Refreshing_from_the_explorers_leader_leaves_the_keys_in_the_explorer()
{
using var workbench = BuildWorkbench();
using var host = BuildHost(workbench);

await HostSteps.Run(host,
() => _commands.TryExecute(CommandIds.FocusSidebar),
() => workbench.StatusBar.DisplayedFocus == "Explorer",
ChangeOnDisk,
() => host.App.InjectKey(Key.Space.WithCtrl),
() => host.App.InjectKey(Key.R),
() => host.App.InjectKey(Key.E),
() => Names(workbench).SequenceEqual(["b.txt"]));

Assert.Equal("Explorer", workbench.StatusBar.DisplayedFocus);
}

private void ChangeOnDisk()
{
_fs.File.Delete("/work/a.txt");
_fs.AddFile("/work/b.txt", new MockFileData("b"));
}

private static string[] Names(Workbench.Workbench workbench) =>
workbench.Sidebar.Explorer.GetChildren(workbench.Sidebar.Explorer.Root!).Select(c => c.Name).ToArray();

private static ActionView? Palette(Workbench.Workbench workbench) =>
workbench.SubViews.OfType<ActionView>().SingleOrDefault();

private Workbench.Workbench BuildWorkbench()
{
var workbench = new Workbench.Workbench(new SidebarPart(new FileExplorerView()), new EditorPart(), new StatusBarPart());
_fs.AddFile("/work/a.txt", new MockFileData("a"));
workbench.Sidebar.Explorer.Open(_fs.DirectoryInfo.New("/work"));
return workbench;
}

private WorkbenchHost BuildHost(Workbench.Workbench workbench, ISettingsService? settings = null) =>
new(workbench, _commands, _keybindings, new InputScopeStack(), settings ?? new InMemorySettingsService(),
driverName: DriverRegistry.Names.ANSI);
}
Loading
Loading