diff --git a/ProjectDirector.Test/BrowserDirectoryTests.cs b/ProjectDirector.Test/BrowserDirectoryTests.cs new file mode 100644 index 0000000..b211603 --- /dev/null +++ b/ProjectDirector.Test/BrowserDirectoryTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.Test; + +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Semantics.Paths; + +/// +/// Covers how the repository browsers tell a directory from a file. +/// +/// +/// The compare browser used to look for a trailing separator on each entry. The entries are +/// s, which do not keep one, so it found no directories at all and listed +/// every directory among the files, where clicking it could not open it. +/// +[TestClass] +public sealed class BrowserDirectoryTests +{ + private static readonly string[] ExpectedDirectories = ["docs", "src"]; + + private static readonly string[] NestedDirectories = [Path.Join("src", "Inner")]; + + private string root = string.Empty; + + [TestInitialize] + public void CreateRepositories() + { + root = Path.Join(Path.GetTempPath(), $"browser-dirs-{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(Path.Join(root, "A", "src")); + _ = Directory.CreateDirectory(Path.Join(root, "B", "docs")); + File.WriteAllText(Path.Join(root, "A", "README.md"), "a"); + File.WriteAllText(Path.Join(root, "B", "README.md"), "b"); + } + + [TestCleanup] + public void DeleteRepositories() => Directory.Delete(root, recursive: true); + + [TestMethod] + public void ARelativePathDoesNotKeepItsTrailingSeparator() + { + RelativePath entry = RelativePath.Create("src" + Path.DirectorySeparatorChar); + + Assert.IsFalse(entry.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal), "If this starts passing, the spelling of an entry says whether it is a directory again."); + } + + [TestMethod] + public void DirectoriesAreFoundInWhicheverRepositoryHasThem() + { + RelativePath[] entries = [Entry("docs"), Entry("README.md"), Entry("src")]; + + Collection directories = ProjectDirector.ListBrowserDirectories(entries, Path.Join(root, "A"), Path.Join(root, "B")); + + Assert.AreSequenceEqual(ExpectedDirectories, directories.Select(x => x.ToString())); + } + + [TestMethod] + public void ANestedEntryIsFoundFromTheRepositoryRoot() + { + _ = Directory.CreateDirectory(Path.Join(root, "A", "src", "Inner")); + File.WriteAllText(Path.Join(root, "A", "src", "a.cs"), "a"); + RelativePath[] entries = [Entry(Path.Join("src", "Inner")), Entry(Path.Join("src", "a.cs"))]; + + Collection directories = ProjectDirector.ListBrowserDirectories(entries, Path.Join(root, "A")); + + Assert.AreSequenceEqual(NestedDirectories, directories.Select(x => x.ToString())); + } + + [TestMethod] + public void AFileIsNeverADirectory() + { + RelativePath[] entries = [Entry("README.md")]; + + Assert.IsEmpty(ProjectDirector.ListBrowserDirectories(entries, Path.Join(root, "A"))); + } + + private static RelativePath Entry(string name) => RelativePath.Create(name + (Path.GetFileName(name).Contains('.', StringComparison.Ordinal) ? string.Empty : Path.DirectorySeparatorChar.ToString())); +} diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 138a267..6c0d724 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -717,6 +717,11 @@ private void Tick(float dt) private void ShowTopPanel(float dt) { + // The divider draws each zone as its own child window, so this window is the whole top zone. + // Marking it lets a picture of the application stop above the splitter and the log. + Vector2 topPanelPosition = ImGui.GetWindowPos(); + ImGuiProbes.MarkRegion("TopPanel", topPanelPosition, topPanelPosition + ImGui.GetWindowSize()); + if (Options.Repos.TryGetValue(Options.BaseRepo, out GitRepository? repo)) { ImGui.TextUnformatted($"Selected Repo: {Options.BaseRepo}"); @@ -885,7 +890,9 @@ private void ShowCollapsiblePanel(string name, Action contentDelegate) } bool wasOpen = open; - if (ImGui.CollapsingHeader(name, flags)) + bool expanded = ImGui.CollapsingHeader(name, flags); + ImGuiProbes.MarkItem("panel", name); + if (expanded) { contentDelegate?.Invoke(); open = true; @@ -2067,7 +2074,7 @@ private static void ShowDiffSummaryText(int linesDeleted, int linesAdded) private void ShowCompareBrowser() { IEnumerable allFilesystemEntries = BrowserContentsBase.Union(BrowserContentsCompare); - Collection directories = allFilesystemEntries.Where(x => x.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)).ToCollection(); + Collection directories = ListBrowserDirectories(allFilesystemEntries, Options.Repos[Options.BaseRepo].LocalPath, Options.Repos[Options.CompareRepo].LocalPath); Collection files = allFilesystemEntries.Except(directories).ToCollection(); if (ImGui.BeginTable("CompareBrowser", 3, ImGuiTableFlags.Borders)) @@ -2238,7 +2245,7 @@ private void ShowRepoBrowser() { Collection allFilesystemEntries = BrowserContentsBase; GitRepository baseRepo = Options.Repos[Options.BaseRepo]; - Collection directories = allFilesystemEntries.Where(x => Directory.Exists(Path.Combine(baseRepo.LocalPath, Options.BrowsePath, x))).ToCollection(); + Collection directories = ListBrowserDirectories(allFilesystemEntries, baseRepo.LocalPath); Collection files = allFilesystemEntries.Except(directories).ToCollection(); bool shouldOpenPopup = false; @@ -2421,6 +2428,20 @@ private void ShowRepoBrowser() internal static IEnumerable ListBrowserEntries(string directory) => Directory.EnumerateFileSystemEntries(directory).Order(StringComparer.OrdinalIgnoreCase); + /// + /// Picks out the entries of a browser listing that are directories in any of the given repositories. + /// + /// The listing, each entry relative to the repository root (so already including the browsed directory). + /// The working trees the listing was taken from. + /// The entries that are directories, in listing order. + /// + /// Asked of the file system rather than read off the entry's spelling: a + /// does not keep a trailing separator, so a test for one finds no directories at all, and the + /// compare browser used to list every directory as a file that could not be opened. + /// + internal static Collection ListBrowserDirectories(IEnumerable entries, params string[] repoRoots) => + entries.Where(entry => repoRoots.Any(root => Directory.Exists(Path.Join(root, entry)))).ToCollection(); + private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, FullyQualifiedGitHubRepoName compareRepo, RelativeDirectoryPath newPath) { Options.BrowsePath = newPath; diff --git a/README.md b/README.md index 4b1b873..e571c8c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ canonical version out to the rest. The [app gallery](docs/gallery/README.md) shows the main views and dialogs as the application draws them today. +[![Two copies of a file compared side by side](docs/gallery/comparing-a-file.png)](docs/gallery/README.md) + ## Requirements - .NET 9.0 diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs b/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs index c8276b6..b85c9d1 100644 --- a/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs +++ b/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs @@ -92,11 +92,15 @@ internal void RightClick(string name) /// Gets everything above the log panel. The log prefixes each line with the wall-clock time it /// was written, so it can never be photographed the same way twice. /// + /// + /// The crop ends at the bottom of the top zone rather than at the log itself, so neither the + /// splitter between the two nor the log's own border is left as a strip along the bottom. + /// /// The part of the window worth keeping. internal Rectangle AboveTheLog() { - Rectangle log = Harness.Probe.Rect("Log") ?? throw new InvalidOperationException("The log panel has not been drawn."); - return new Rectangle(0, 0, Harness.Options.Width, log.MinY - 1); + Rectangle top = Harness.Probe.Rect("TopPanel") ?? throw new InvalidOperationException("The top panel has not been drawn."); + return new Rectangle(0, 0, Harness.Options.Width, top.MaxY); } /// diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs b/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs index 28bdc71..12f7c05 100644 --- a/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs +++ b/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs @@ -55,6 +55,12 @@ internal static class GalleryCatalog app => { app.SelectRepository(GalleryRepositories.Alpha); + + // The popup opens in the middle of the window, which with every panel open is across + // the similar repositories table, leaving the tops of its glyphs showing above the + // title bar. Folding the git actions away lifts the table clear of it. + app.Harness.Click("panel/Git Actions"); + app.Harness.Step(2); app.RightClick("browse/README.md"); app.Harness.Click("browse-menu/Propagate"); app.Harness.Step(3); @@ -80,9 +86,13 @@ internal static class GalleryCatalog private static Rectangle? AboveTheLog(GalleryApp app) => app.AboveTheLog(); - /// Opens a popup from the File menu. The popup is only ever shown, never confirmed. + /// + /// Opens a popup from the File menu over a selected repository, so the popup is seen against the + /// application rather than an empty panel. The popup is only ever shown, never confirmed. + /// private static void OpenMenuItem(GalleryApp app, string item) { + app.SelectRepository(GalleryRepositories.Alpha); app.Harness.Click("menu/File"); app.Harness.Step(2); app.Harness.Click(item);