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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ What follows is how TuiCode implements it today.
## Editor gutter (#23)

- `EditorGutter` is a sibling view left of the `EditorTextView` inside `EditorTab`, not an adornment. It draws rows from `_text.Viewport.Y` (we never enable WordWrap, so viewport rows are model rows) and redraws on the text view's `ViewportChanged` / `UnwrappedCursorPositionChanged`. Anything that moves the text view (e.g. `SetHeader`) must move the gutter's `Y` too.
- Change markers diff the buffer against a **baseline** via the pure, TG-free `LineDiff` (Myers, after trimming common prefix/suffix). For a file git tracks it's the file at `HEAD` (#370), so a save doesn't clear what isn't committed; otherwise it's the lines at load, reset on `Save` and reload. `CommittedBaselines` (`Workbench/Git/`) reads `HEAD` in the background on open, save and reload (`EditorTab.BaselineReset`) with `git cat-file --filters`, so CRLF and `.gitattributes` checkouts compare clean, and hands it to `EditorTab.CommittedLines`; a read overtaken by a newer one, or by the tab closing, is dropped. Outside a repo (`GitRepository.Contains`, no git call), untracked, new or renamed since `HEAD`, or git failing, it stays null and the save baseline stands, quietly. Undoing back to the baseline clears the markers, which `IsDirty` doesn't. The diff is lazy: `EditorTab.OnEdited` invalidates it and the next draw recomputes, so a hidden gutter costs nothing. Past `LineDiff.MaxEdits` Myers gives up, and the gutter splits the region at lines that occur once on each side (as patience diff does) and diffs between them, so a branch's worth of changes still reads line by line; only a region with no such line is marked modified whole. `LineDiff.Hunks`, which the undo recorder and `AlignedDiff` use, still gives up outright.
- Change markers diff the buffer against a **baseline** via the pure, TG-free `LineDiff` (Myers, after trimming common prefix/suffix). For a file git tracks it's the file at `HEAD` (#370), so a save doesn't clear what isn't committed; otherwise it's the lines at load, reset on `Save` and reload. `CommittedBaselines` (`Workbench/Git/`) reads `HEAD` in the background on open, save and reload (`EditorTab.BaselineReset`) with `git cat-file --filters`, so CRLF and `.gitattributes` checkouts compare clean, and hands it to `EditorTab.CommittedLines`; a read overtaken by a newer one, or by the tab closing, is dropped. Outside a repo (`GitRepository.Contains`, no git call), untracked, new or renamed since `HEAD`, or git failing, it stays null and the save baseline stands, quietly. `HEAD` moving outside the editor — a commit, checkout, reset or pull in another pane — re-reads every open tab from that repo (#371): `HeadWatcher` watches each repo's git dir for `HEAD`, and its common dir for `packed-refs` and, recursively, `refs/heads`, which `GitRepository.Dirs` finds from the filesystem (a linked worktree's `.git` file names its own git dir, whose `commondir` names the shared one). Only `HEAD`, `packed-refs` and the branch `HEAD` names count, so `git status` rewriting the index reads nothing. Events are debounced 250 ms like `DiskWatcher`'s, and a repo whose watch couldn't be set up re-reads a tab when it's shown instead. Undoing back to the baseline clears the markers, which `IsDirty` doesn't. The diff is lazy: `EditorTab.OnEdited` invalidates it and the next draw recomputes, so a hidden gutter costs nothing. Past `LineDiff.MaxEdits` Myers gives up, and the gutter splits the region at lines that occur once on each side (as patience diff does) and diffs between them, so a branch's worth of changes still reads line by line; only a region with no such line is marked modified whole. `LineDiff.Hunks`, which the undo recorder and `AlignedDiff` use, still gives up outright.
- `AlignedDiff` (the model for the side-by-side diff tab, #61) lays `LineDiff.Hunks` out as rows: inside a hunk, lines pair as modified until the shorter side runs out, then the rest are left- or right-only. It takes its own give-up limit, since a revision can be far more than `MaxEdits` away from the buffer.
- The diff's input comes from `LineSnapshot` (`EditorTextView.Snapshot`, shared with syntax colouring), not `EditorTextView.LineStrings` (which builds a string for every line). TG edits a line's `List<Cell>` in place, so the snapshot spots changed lines by reference plus `List<T>`'s private `_version` (read via `[UnsafeAccessor]`, AOT-safe), trims the unchanged prefix/suffix, and re-reads only the middle. Unchanged lines keep their string instances, so `LineDiff`'s comparisons against the baseline hit `string.Equals`' reference fast path. Don't use `ContentsChanged`'s row as a change range instead: TG reports the start of some multi-line edits and the end of others, and raises nothing for the kill commands.
- Edits must reach `EditorTab.OnEdited`, which is why the `Content` setter calls it directly. `TextView.Text =` raises `ContentsChanged` as well in TG 2.1.0, so setting the whole buffer arrives there anyway — which is why a reload (#269) holds `EditorTab._loading` over it: taking up the file on disk isn't an edit and mustn't dirty the tab.
Expand Down
39 changes: 33 additions & 6 deletions src/TuiCode.Workbench/Git/CommittedBaselines.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,28 @@ namespace TuiCode.Workbench.Git;

/// <summary>
/// Gives each tab its file at <c>HEAD</c> to mark changes against, so a save doesn't clear what isn't committed
/// (#370). Read in the background on open, save and reload; wherever git has no answer the tab keeps its last save.
/// (#370). Read in the background on open, save and reload, and again for every tab in a repo whose <c>HEAD</c>
/// moves (#371); wherever git has no answer the tab keeps its last save.
/// </summary>
internal sealed class CommittedBaselines
internal sealed class CommittedBaselines : IDisposable
{
private readonly EditorGroup _group;
private readonly IGitCli _git;
private readonly Action<Action> _post;
private readonly Dictionary<EditorTab, (string Path, int Read)> _reads = [];
private readonly HeadWatcher _head;
private readonly Dictionary<EditorTab, (string Path, GitDirs? Repo, int Read)> _reads = [];
private readonly List<Task> _pending = [];

public CommittedBaselines(EditorGroup group, IGitCli git, Action<Action> post)
public CommittedBaselines(EditorGroup group, IGitCli git, Action<Action> post, HeadWatcher head)
{
_group = group;
_git = git;
_post = post;
_head = head;
_group.TabsChanged += (_, _) => Follow();
_group.BaselineReset += (_, tab) => Read(tab);
_group.ActiveTabChanged += OnActiveTabChanged;
_head.Moved += OnHeadMoved;
Follow();
}

Expand All @@ -42,13 +47,28 @@ private void Follow()
foreach (var tab in open)
if (!_reads.TryGetValue(tab, out var read) || read.Path != tab.File.FullName)
Read(tab);
_head.Follow(_reads.Values.Select(r => r.Repo).OfType<GitDirs>().Distinct());
}

private void OnHeadMoved(object? sender, IReadOnlyList<GitDirs> repos)
{
foreach (var (tab, read) in _reads.ToList())
if (read.Repo is { } repo && repos.Contains(repo))
Read(tab);
}

private void OnActiveTabChanged(object? sender, EditorTab? tab)
{
if (tab is not null && _reads.TryGetValue(tab, out var read) && read.Repo is { } repo && !_head.IsWatching(repo))
Read(tab);
}

private void Read(EditorTab tab)
{
var path = tab.File.FullName;
var read = _reads.TryGetValue(tab, out var last) ? last.Read + 1 : 0;
_reads[tab] = (path, read);
var repo = last.Path == path ? last.Repo : GitRepository.Dirs(tab.File.Directory);
_reads[tab] = (path, repo, read);
if (!GitRepository.Contains(tab.File.Directory))
{
if (tab.CommittedLines is not null) tab.CommittedLines = null;
Expand All @@ -60,7 +80,7 @@ await _git.ShowCheckedOutFileAsync(path) is { Succeeded: true, Value: { } text }
.ContinueWith(t => _post(() =>
{
// A read that returns after a newer one started, or after the tab closed, is stale.
if (_reads.TryGetValue(tab, out var current) && current == (path, read))
if (_reads.TryGetValue(tab, out var current) && current.Path == path && current.Read == read)
tab.CommittedLines = t.IsCompletedSuccessfully ? t.Result : null;
}), TaskScheduler.Default);
lock (_pending)
Expand All @@ -69,4 +89,11 @@ await _git.ShowCheckedOutFileAsync(path) is { Succeeded: true, Value: { } text }
_pending.Add(reading);
}
}

public void Dispose()
{
_group.ActiveTabChanged -= OnActiveTabChanged;
_head.Moved -= OnHeadMoved;
_head.Dispose();
}
}
38 changes: 35 additions & 3 deletions src/TuiCode.Workbench/Git/GitRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,45 @@ internal static class GitRepository
/// Walks up for <c>.git</c> — a directory, or the file a worktree or submodule has instead. Command
/// enablement is read while the palette builds its rows, which <see cref="GitCli"/> can't answer in time.
/// </summary>
public static bool Contains(IDirectoryInfo? folder)
public static bool Contains(IDirectoryInfo? folder) => DotGit(folder) is not null;

/// <summary>Where <c>HEAD</c> and the branches live; null outside a repo or for a <c>.git</c> file we can't read.</summary>
public static GitDirs? Dirs(IDirectoryInfo? folder)
{
if (DotGit(folder) is not { } dotGit) return null;
var fs = folder!.FileSystem;
if (fs.Directory.Exists(dotGit)) return new GitDirs(dotGit, dotGit);

try
{
var line = fs.File.ReadLines(dotGit).FirstOrDefault();
if (line is null || !line.StartsWith("gitdir:", StringComparison.Ordinal)) return null;
var gitDir = Resolve(fs, fs.Path.GetDirectoryName(dotGit)!, line["gitdir:".Length..].Trim());
var commonDirFile = fs.Path.Combine(gitDir, "commondir");
var commonDir = fs.File.Exists(commonDirFile)
? Resolve(fs, gitDir, fs.File.ReadAllText(commonDirFile).Trim())
: gitDir;
return new GitDirs(gitDir, commonDir);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException)
{
return null;
}
}

private static string? DotGit(IDirectoryInfo? folder)
{
for (var dir = folder; dir is not null; dir = dir.Parent)
{
var dotGit = dir.FileSystem.Path.Combine(dir.FullName, ".git");
if (dir.FileSystem.Directory.Exists(dotGit) || dir.FileSystem.File.Exists(dotGit)) return true;
if (dir.FileSystem.Directory.Exists(dotGit) || dir.FileSystem.File.Exists(dotGit)) return dotGit;
}
return false;
return null;
}

private static string Resolve(IFileSystem fs, string from, string path) =>
fs.Path.TrimEndingDirectorySeparator(fs.Path.GetFullPath(fs.Path.Combine(from, path)));
}

/// <summary>A linked worktree's own <c>HEAD</c> is in <see cref="GitDir"/>; the branches it shares are in <see cref="CommonDir"/>.</summary>
internal sealed record GitDirs(string GitDir, string CommonDir);
170 changes: 170 additions & 0 deletions src/TuiCode.Workbench/Git/HeadWatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using Microsoft.Extensions.Logging;

namespace TuiCode.Workbench.Git;

/// <summary>Notices <c>HEAD</c> moving in the open tabs' repos, whoever moved it (#371).</summary>
internal sealed class HeadWatcher : IDisposable
{
// A rebase moves HEAD once per commit it replays.
private static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(250);

private const NotifyFilters Events = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size;

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<GitDirs> _followed = [];
private readonly HashSet<string> _pending = new(StringComparer.Ordinal);
private readonly Lock _gate = new();
private bool _armed;
private bool _disposed;

/// <summary>The repos whose <c>HEAD</c> may have moved, once the events have settled. Raised on the UI thread.</summary>
public event EventHandler<IReadOnlyList<GitDirs>>? Moved;

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

internal int WatcherCount
{
get { lock (_gate) return _watchers.Count; }
}

public bool IsWatching(GitDirs repo)
{
lock (_gate) return Directories(repo).All(d => _watchers.ContainsKey(d.Path));
}

/// <summary>Watch exactly <paramref name="repos"/>, adding and dropping watchers to match.</summary>
public void Follow(IEnumerable<GitDirs> repos)
{
if (_disposed) return;
List<(string Path, bool Recursive)> missing;
lock (_gate)
{
_followed.Clear();
_followed.UnionWith(repos);
var wanted = _followed.SelectMany(Directories).Distinct().ToList();
foreach (var gone in _watchers.Keys.Where(d => wanted.All(w => w.Path != d)).ToList()) Drop(gone);
missing = wanted.Where(w => !_watchers.ContainsKey(w.Path)).ToList();
}
foreach (var (path, recursive) in missing) Add(path, recursive);
}

private IEnumerable<(string Path, bool Recursive)> Directories(GitDirs repo)
{
yield return (repo.GitDir, false);
if (repo.CommonDir != repo.GitDir) yield return (repo.CommonDir, false);
yield return (BranchesOf(repo), true);
}

private string BranchesOf(GitDirs repo) => _fileSystem.Path.Combine(repo.CommonDir, "refs", "heads");

private void Add(string directory, bool recursive)
{
IFileSystemWatcher? watcher = null;
try
{
watcher = _fileSystem.FileSystemWatcher.New(directory);
watcher.IncludeSubdirectories = recursive;
watcher.NotifyFilter = Events;
watcher.Changed += (_, e) => OnEvent(e, recursive);
watcher.Created += (_, e) => OnEvent(e, recursive);
watcher.Deleted += (_, e) => OnEvent(e, recursive);
watcher.Renamed += (_, e) => OnEvent(e, recursive);
watcher.Error += (_, e) => OnError(directory, e.GetException());
watcher.EnableRaisingEvents = true;
lock (_gate) _watchers[directory] = watcher;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or NotImplementedException)
{
watcher?.Dispose();
_logger.LogWarning(e, "Not watching {Directory} for commits and checkouts", directory);
}
}

private void OnError(string directory, Exception exception)
{
_logger.LogWarning(exception, "Stopped watching {Directory} for commits and checkouts", directory);
lock (_gate) Drop(directory);
}

// Under _gate.
private void Drop(string directory)
{
if (_watchers.Remove(directory, out var watcher)) watcher.Dispose();
}

// Git writes a ref to <name>.lock and renames it into place, so the rename is what lands on the ref.
private void OnEvent(FileSystemEventArgs e, bool branches)
{
if (_disposed) return;
var name = _fileSystem.Path.GetFileName(e.FullPath);
if (branches ? name.EndsWith(".lock", StringComparison.Ordinal) : name is not ("HEAD" or "packed-refs")) return;
var path = _fileSystem.Path.GetFullPath(e.FullPath);
lock (_gate)
{
_pending.Add(path);
if (_armed) return;
_armed = true;
}
_schedule(Debounce, Flush);
}

private void Flush()
{
string[] changed;
GitDirs[] followed;
lock (_gate)
{
_armed = false;
changed = _pending.ToArray();
_pending.Clear();
followed = _followed.ToArray();
}
if (_disposed) return;
var moved = followed.Where(repo => HeadPaths(repo).Any(p => changed.Contains(p, StringComparer.Ordinal))).ToList();
if (moved.Count > 0) Moved?.Invoke(this, moved);
}

private IEnumerable<string> HeadPaths(GitDirs repo)
{
var head = _fileSystem.Path.Combine(repo.GitDir, "HEAD");
yield return head;
yield return _fileSystem.Path.Combine(repo.CommonDir, "packed-refs");
if (Branch(head) is { } branch)
yield return _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(BranchesOf(repo), branch));
}

// Null when HEAD is detached.
private string? Branch(string head)
{
const string prefix = "ref: refs/heads/";
try
{
var line = _fileSystem.File.ReadLines(head).FirstOrDefault();
return line is not null && line.StartsWith(prefix, StringComparison.Ordinal) ? line[prefix.Length..].Trim() : null;
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
return null;
}
}

public void Dispose()
{
_disposed = true;
lock (_gate)
{
foreach (var directory in _watchers.Keys.ToList()) Drop(directory);
_followed.Clear();
_pending.Clear();
}
}
}
4 changes: 3 additions & 1 deletion src/TuiCode.Workbench/WorkbenchHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@

RegisterDefaultCommands();
_menu = new CommandMenu(_workbench.MenuBar, _commands, _keybindings, () => !_workbench.HasDialog,
id => IsAvailableFrom(id, _menu.IsOpen ? _menuOpenedFrom : _focus.Region));

Check warning on line 160 in src/TuiCode.Workbench/WorkbenchHost.cs

View workflow job for this annotation

GitHub Actions / AOT linux-x64

Dereference of a possibly null reference.

Check warning on line 160 in src/TuiCode.Workbench/WorkbenchHost.cs

View workflow job for this annotation

GitHub Actions / AOT linux-arm64

Dereference of a possibly null reference.

Check warning on line 160 in src/TuiCode.Workbench/WorkbenchHost.cs

View workflow job for this annotation

GitHub Actions / AOT osx-arm64

Dereference of a possibly null reference.

Check warning on line 160 in src/TuiCode.Workbench/WorkbenchHost.cs

View workflow job for this annotation

GitHub Actions / AOT win-x64

Dereference of a possibly null reference.

Check warning on line 160 in src/TuiCode.Workbench/WorkbenchHost.cs

View workflow job for this annotation

GitHub Actions / AOT win-arm64

Dereference of a possibly null reference.

Check warning on line 160 in src/TuiCode.Workbench/WorkbenchHost.cs

View workflow job for this annotation

GitHub Actions / build-test (ubuntu-latest)

Dereference of a possibly null reference.

Check warning on line 160 in src/TuiCode.Workbench/WorkbenchHost.cs

View workflow job for this annotation

GitHub Actions / build-test (windows-latest)

Dereference of a possibly null reference.

Check warning on line 160 in src/TuiCode.Workbench/WorkbenchHost.cs

View workflow job for this annotation

GitHub Actions / build-test (windows-latest)

Dereference of a possibly null reference.
_menu.Opened += (_, _) => OnMenuOpened();
_menu.Closed += (_, picked) => OnMenuClosed(picked);
ApplyKeybindings(_settings.KeybindingOverrides);
Expand Down Expand Up @@ -185,7 +185,8 @@
_workbench.Sidebar.Explorer,
_workbench.StatusBar.SetMessage,
new DiskWatcher(fileSystem, ScheduleFlush, _logger));
_baselines = new CommittedBaselines(_workbench.Editor.Group, _git, action => _app.Invoke(action));
_baselines = new CommittedBaselines(
_workbench.Editor.Group, _git, action => _app.Invoke(action), new HeadWatcher(fileSystem, ScheduleFlush, _logger));

var explorer = _workbench.Sidebar.Explorer;
_folderWatcher = new FolderWatcher(fileSystem, ScheduleFlush, _logger);
Expand Down Expand Up @@ -2718,6 +2719,7 @@
_workbench.Editor.Group.CursorMoved -= OnEditorCursorMoved;
_workbench.Editor.Group.ActiveTabChanged -= OnActiveTabChanged;
_diskChanges.Dispose();
_baselines.Dispose();
_folderWatcher.Dispose();
_find.Dispose();
_terminalCursors.Dispose();
Expand Down
Loading
Loading